浏览代码

Merge pull request #5974 from ChatGPTNextWeb/feat-mcp

Support MCP( WIP)
RiverRay 10 月之前
父节点
当前提交
4cad55379d

+ 5 - 0
.env.template

@@ -7,6 +7,11 @@ CODE=your-password
 # You can start service behind a proxy. (optional)
 PROXY_URL=http://localhost:7890
 
+# Enable MCP functionality (optional)
+# Default: Empty (disabled)
+# Set to "true" to enable MCP functionality
+ENABLE_MCP=
+
 # (optional)
 # Default: Empty
 # Google Gemini Pro API key, set if you want to use Google Gemini Pro API.

+ 2 - 1
.eslintignore

@@ -1 +1,2 @@
-public/serviceWorker.js
+public/serviceWorker.js
+app/mcp/mcp_config.json

+ 3 - 0
.gitignore

@@ -46,3 +46,6 @@ dev
 *.key.pub
 
 masks.json
+
+# mcp config
+app/mcp/mcp_config.json

+ 4 - 0
Dockerfile

@@ -34,12 +34,16 @@ ENV PROXY_URL=""
 ENV OPENAI_API_KEY=""
 ENV GOOGLE_API_KEY=""
 ENV CODE=""
+ENV ENABLE_MCP=""
 
 COPY --from=builder /app/public ./public
 COPY --from=builder /app/.next/standalone ./
 COPY --from=builder /app/.next/static ./.next/static
 COPY --from=builder /app/.next/server ./.next/server
 
+RUN mkdir -p /app/app/mcp && chmod 777 /app/app/mcp
+COPY --from=builder /app/app/mcp/mcp_config.json /app/app/mcp/
+
 EXPOSE 3000
 
 CMD if [ -n "$PROXY_URL" ]; then \

+ 15 - 1
README_CN.md

@@ -6,7 +6,7 @@
 
 <h1 align="center">NextChat</h1>
 
-一键免费部署你的私人 ChatGPT 网页应用,支持 GPT3, GPT4 & Gemini Pro 模型。
+一键免费部署你的私人 ChatGPT 网页应用,支持 Claude, GPT4 & Gemini Pro 模型。
 
 [NextChatAI](https://nextchat.dev/chat?utm_source=readme) / [企业版](#%E4%BC%81%E4%B8%9A%E7%89%88) / [演示 Demo](https://chat-gpt-next-web.vercel.app/) / [反馈 Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) / [加入 Discord](https://discord.gg/zrhvHCr79N)
 
@@ -262,6 +262,10 @@ Stability API密钥
 
 自定义的Stability API请求地址
 
+### `ENABLE_MCP` (optional)
+
+启用MCP(Model Context Protocol)功能
+
 
 ## 开发
 
@@ -315,6 +319,16 @@ docker run -d -p 3000:3000 \
    yidadaa/chatgpt-next-web
 ```
 
+如需启用 MCP 功能,可以使用:
+
+```shell
+docker run -d -p 3000:3000 \
+   -e OPENAI_API_KEY=sk-xxxx \
+   -e CODE=页面访问密码 \
+   -e ENABLE_MCP=true \
+   yidadaa/chatgpt-next-web
+```
+
 如果你的本地代理需要账号密码,可以使用:
 
 ```shell

+ 295 - 243
app/components/chat.tsx

@@ -1,17 +1,18 @@
 import { useDebouncedCallback } from "use-debounce";
 import React, {
-  useState,
-  useRef,
-  useEffect,
-  useMemo,
-  useCallback,
   Fragment,
   RefObject,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from "react";
 
 import SendWhiteIcon from "../icons/send-white.svg";
 import BrainIcon from "../icons/brain.svg";
 import RenameIcon from "../icons/rename.svg";
+import EditIcon from "../icons/rename.svg";
 import ExportIcon from "../icons/share.svg";
 import ReturnIcon from "../icons/return.svg";
 import CopyIcon from "../icons/copy.svg";
@@ -24,11 +25,11 @@ import MaskIcon from "../icons/mask.svg";
 import MaxIcon from "../icons/max.svg";
 import MinIcon from "../icons/min.svg";
 import ResetIcon from "../icons/reload.svg";
+import ReloadIcon from "../icons/reload.svg";
 import BreakIcon from "../icons/break.svg";
 import SettingsIcon from "../icons/chat-settings.svg";
 import DeleteIcon from "../icons/clear.svg";
 import PinIcon from "../icons/pin.svg";
-import EditIcon from "../icons/rename.svg";
 import ConfirmIcon from "../icons/confirm.svg";
 import CloseIcon from "../icons/close.svg";
 import CancelIcon from "../icons/cancel.svg";
@@ -45,32 +46,29 @@ import QualityIcon from "../icons/hd.svg";
 import StyleIcon from "../icons/palette.svg";
 import PluginIcon from "../icons/plugin.svg";
 import ShortcutkeyIcon from "../icons/shortcutkey.svg";
-import ReloadIcon from "../icons/reload.svg";
+import McpToolIcon from "../icons/tool.svg";
 import HeadphoneIcon from "../icons/headphone.svg";
 import {
-  ChatMessage,
-  SubmitKey,
-  useChatStore,
   BOT_HELLO,
+  ChatMessage,
   createMessage,
-  useAccessStore,
-  Theme,
-  useAppConfig,
   DEFAULT_TOPIC,
   ModelType,
+  SubmitKey,
+  Theme,
+  useAccessStore,
+  useAppConfig,
+  useChatStore,
   usePluginStore,
 } from "../store";
 
 import {
-  copyToClipboard,
-  selectOrCopy,
   autoGrowTextArea,
-  useMobileScreen,
-  getMessageTextContent,
+  copyToClipboard,
   getMessageImages,
-  isVisionModel,
+  getMessageTextContent,
   isDalle3,
-  showPlugins,
+  isVisionModel,
   safeLocalStorage,
   getModelSizes,
   supportsCustomSize,
@@ -104,8 +102,8 @@ import {
   ModelProvider,
   Path,
   REQUEST_TIMEOUT_MS,
-  UNFINISHED_INPUT,
   ServiceProvider,
+  UNFINISHED_INPUT,
 } from "../constant";
 import { Avatar } from "./emoji";
 import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@@ -115,9 +113,7 @@ import { prettyObject } from "../utils/format";
 import { ExportMessageModal } from "./exporter";
 import { getClientConfig } from "../config/client";
 import { useAllModels } from "../utils/hooks";
-import { MultimodalContent } from "../client/api";
-
-import { ClientApi } from "../client/api";
+import { ClientApi, MultimodalContent } from "../client/api";
 import { createTTSPlayer } from "../utils/audio";
 import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
 
@@ -125,6 +121,7 @@ import { isEmpty } from "lodash-es";
 import { getModelProvider } from "../utils/model";
 import { RealtimeChat } from "@/app/components/realtime-chat";
 import clsx from "clsx";
+import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
 
 const localStorage = safeLocalStorage();
 
@@ -134,6 +131,34 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
   loading: () => <LoadingIcon />,
 });
 
+const MCPAction = () => {
+  const navigate = useNavigate();
+  const [count, setCount] = useState<number>(0);
+  const [mcpEnabled, setMcpEnabled] = useState(false);
+
+  useEffect(() => {
+    const checkMcpStatus = async () => {
+      const enabled = await isMcpEnabled();
+      setMcpEnabled(enabled);
+      if (enabled) {
+        const count = await getAvailableClientsCount();
+        setCount(count);
+      }
+    };
+    checkMcpStatus();
+  }, []);
+
+  if (!mcpEnabled) return null;
+
+  return (
+    <ChatAction
+      onClick={() => navigate(Path.McpMarket)}
+      text={`MCP${count ? ` (${count})` : ""}`}
+      icon={<McpToolIcon />}
+    />
+  );
+};
+
 export function SessionConfigModel(props: { onClose: () => void }) {
   const chatStore = useChatStore();
   const session = chatStore.currentSession();
@@ -425,11 +450,11 @@ export function ChatAction(props: {
 function useScrollToBottom(
   scrollRef: RefObject<HTMLDivElement>,
   detach: boolean = false,
+  messages: ChatMessage[],
 ) {
   // for auto-scroll
-
   const [autoScroll, setAutoScroll] = useState(true);
-  function scrollDomToBottom() {
+  const scrollDomToBottom = useCallback(() => {
     const dom = scrollRef.current;
     if (dom) {
       requestAnimationFrame(() => {
@@ -437,7 +462,7 @@ function useScrollToBottom(
         dom.scrollTo(0, dom.scrollHeight);
       });
     }
-  }
+  }, [scrollRef]);
 
   // auto scroll
   useEffect(() => {
@@ -446,6 +471,15 @@ function useScrollToBottom(
     }
   });
 
+  // auto scroll when messages length changes
+  const lastMessagesLength = useRef(messages.length);
+  useEffect(() => {
+    if (messages.length > lastMessagesLength.current && !detach) {
+      scrollDomToBottom();
+    }
+    lastMessagesLength.current = messages.length;
+  }, [messages.length, detach, scrollDomToBottom]);
+
   return {
     scrollRef,
     autoScroll,
@@ -475,6 +509,7 @@ export function ChatActions(props: {
 
   // switch themes
   const theme = config.theme;
+
   function nextTheme() {
     const themes = [Theme.Auto, Theme.Light, Theme.Dark];
     const themeIndex = themes.indexOf(theme);
@@ -794,6 +829,7 @@ export function ChatActions(props: {
             icon={<ShortcutkeyIcon />}
           />
         )}
+        {!isMobileScreen && <MCPAction />}
       </>
       <div className={styles["chat-input-actions-end"]}>
         {config.realtimeConfig.enable && (
@@ -987,6 +1023,7 @@ function _Chat() {
   const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
     scrollRef,
     (isScrolledToBottom || isAttachWithTop) && !isTyping,
+    session.messages,
   );
   const [hitBottom, setHitBottom] = useState(true);
   const isMobileScreen = useMobileScreen();
@@ -1246,6 +1283,7 @@ function _Chat() {
   const accessStore = useAccessStore();
   const [speechStatus, setSpeechStatus] = useState(false);
   const [speechLoading, setSpeechLoading] = useState(false);
+
   async function openaiSpeech(text: string) {
     if (speechStatus) {
       ttsPlayer.stop();
@@ -1345,6 +1383,7 @@ function _Chat() {
   const [msgRenderIndex, _setMsgRenderIndex] = useState(
     Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
   );
+
   function setMsgRenderIndex(newIndex: number) {
     newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
     newIndex = Math.max(0, newIndex);
@@ -1380,6 +1419,7 @@ function _Chat() {
     setHitBottom(isHitBottom);
     setAutoScroll(isHitBottom);
   };
+
   function scrollToBottom() {
     setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
     scrollDomToBottom();
@@ -1737,252 +1777,264 @@ function _Chat() {
                 setAutoScroll(false);
               }}
             >
-              {messages.map((message, i) => {
-                const isUser = message.role === "user";
-                const isContext = i < context.length;
-                const showActions =
-                  i > 0 &&
-                  !(message.preview || message.content.length === 0) &&
-                  !isContext;
-                const showTyping = message.preview || message.streaming;
-
-                const shouldShowClearContextDivider =
-                  i === clearContextIndex - 1;
-
-                return (
-                  <Fragment key={message.id}>
-                    <div
-                      className={
-                        isUser
-                          ? styles["chat-message-user"]
-                          : styles["chat-message"]
-                      }
-                    >
-                      <div className={styles["chat-message-container"]}>
-                        <div className={styles["chat-message-header"]}>
-                          <div className={styles["chat-message-avatar"]}>
-                            <div className={styles["chat-message-edit"]}>
-                              <IconButton
-                                icon={<EditIcon />}
-                                aria={Locale.Chat.Actions.Edit}
-                                onClick={async () => {
-                                  const newMessage = await showPrompt(
-                                    Locale.Chat.Actions.Edit,
-                                    getMessageTextContent(message),
-                                    10,
-                                  );
-                                  let newContent: string | MultimodalContent[] =
-                                    newMessage;
-                                  const images = getMessageImages(message);
-                                  if (images.length > 0) {
-                                    newContent = [
-                                      { type: "text", text: newMessage },
-                                    ];
-                                    for (let i = 0; i < images.length; i++) {
-                                      newContent.push({
-                                        type: "image_url",
-                                        image_url: {
-                                          url: images[i],
-                                        },
-                                      });
+              {messages
+                // TODO
+                // .filter((m) => !m.isMcpResponse)
+                .map((message, i) => {
+                  const isUser = message.role === "user";
+                  const isContext = i < context.length;
+                  const showActions =
+                    i > 0 &&
+                    !(message.preview || message.content.length === 0) &&
+                    !isContext;
+                  const showTyping = message.preview || message.streaming;
+
+                  const shouldShowClearContextDivider =
+                    i === clearContextIndex - 1;
+
+                  return (
+                    <Fragment key={message.id}>
+                      <div
+                        className={
+                          isUser
+                            ? styles["chat-message-user"]
+                            : styles["chat-message"]
+                        }
+                      >
+                        <div className={styles["chat-message-container"]}>
+                          <div className={styles["chat-message-header"]}>
+                            <div className={styles["chat-message-avatar"]}>
+                              <div className={styles["chat-message-edit"]}>
+                                <IconButton
+                                  icon={<EditIcon />}
+                                  aria={Locale.Chat.Actions.Edit}
+                                  onClick={async () => {
+                                    const newMessage = await showPrompt(
+                                      Locale.Chat.Actions.Edit,
+                                      getMessageTextContent(message),
+                                      10,
+                                    );
+                                    let newContent:
+                                      | string
+                                      | MultimodalContent[] = newMessage;
+                                    const images = getMessageImages(message);
+                                    if (images.length > 0) {
+                                      newContent = [
+                                        { type: "text", text: newMessage },
+                                      ];
+                                      for (let i = 0; i < images.length; i++) {
+                                        newContent.push({
+                                          type: "image_url",
+                                          image_url: {
+                                            url: images[i],
+                                          },
+                                        });
+                                      }
                                     }
-                                  }
-                                  chatStore.updateTargetSession(
-                                    session,
-                                    (session) => {
-                                      const m = session.mask.context
-                                        .concat(session.messages)
-                                        .find((m) => m.id === message.id);
-                                      if (m) {
-                                        m.content = newContent;
+                                    chatStore.updateTargetSession(
+                                      session,
+                                      (session) => {
+                                        const m = session.mask.context
+                                          .concat(session.messages)
+                                          .find((m) => m.id === message.id);
+                                        if (m) {
+                                          m.content = newContent;
+                                        }
+                                      },
+                                    );
+                                  }}
+                                ></IconButton>
+                              </div>
+                              {isUser ? (
+                                <Avatar avatar={config.avatar} />
+                              ) : (
+                                <>
+                                  {["system"].includes(message.role) ? (
+                                    <Avatar avatar="2699-fe0f" />
+                                  ) : (
+                                    <MaskAvatar
+                                      avatar={session.mask.avatar}
+                                      model={
+                                        message.model ||
+                                        session.mask.modelConfig.model
                                       }
-                                    },
-                                  );
-                                }}
-                              ></IconButton>
+                                    />
+                                  )}
+                                </>
+                              )}
                             </div>
-                            {isUser ? (
-                              <Avatar avatar={config.avatar} />
-                            ) : (
-                              <>
-                                {["system"].includes(message.role) ? (
-                                  <Avatar avatar="2699-fe0f" />
-                                ) : (
-                                  <MaskAvatar
-                                    avatar={session.mask.avatar}
-                                    model={
-                                      message.model ||
-                                      session.mask.modelConfig.model
-                                    }
-                                  />
-                                )}
-                              </>
+                            {!isUser && (
+                              <div className={styles["chat-model-name"]}>
+                                {message.model}
+                              </div>
                             )}
-                          </div>
-                          {!isUser && (
-                            <div className={styles["chat-model-name"]}>
-                              {message.model}
-                            </div>
-                          )}
-
-                          {showActions && (
-                            <div className={styles["chat-message-actions"]}>
-                              <div className={styles["chat-input-actions"]}>
-                                {message.streaming ? (
-                                  <ChatAction
-                                    text={Locale.Chat.Actions.Stop}
-                                    icon={<StopIcon />}
-                                    onClick={() => onUserStop(message.id ?? i)}
-                                  />
-                                ) : (
-                                  <>
-                                    <ChatAction
-                                      text={Locale.Chat.Actions.Retry}
-                                      icon={<ResetIcon />}
-                                      onClick={() => onResend(message)}
-                                    />
 
+                            {showActions && (
+                              <div className={styles["chat-message-actions"]}>
+                                <div className={styles["chat-input-actions"]}>
+                                  {message.streaming ? (
                                     <ChatAction
-                                      text={Locale.Chat.Actions.Delete}
-                                      icon={<DeleteIcon />}
-                                      onClick={() => onDelete(message.id ?? i)}
-                                    />
-
-                                    <ChatAction
-                                      text={Locale.Chat.Actions.Pin}
-                                      icon={<PinIcon />}
-                                      onClick={() => onPinMessage(message)}
-                                    />
-                                    <ChatAction
-                                      text={Locale.Chat.Actions.Copy}
-                                      icon={<CopyIcon />}
+                                      text={Locale.Chat.Actions.Stop}
+                                      icon={<StopIcon />}
                                       onClick={() =>
-                                        copyToClipboard(
-                                          getMessageTextContent(message),
-                                        )
+                                        onUserStop(message.id ?? i)
                                       }
                                     />
-                                    {config.ttsConfig.enable && (
+                                  ) : (
+                                    <>
                                       <ChatAction
-                                        text={
-                                          speechStatus
-                                            ? Locale.Chat.Actions.StopSpeech
-                                            : Locale.Chat.Actions.Speech
-                                        }
-                                        icon={
-                                          speechStatus ? (
-                                            <SpeakStopIcon />
-                                          ) : (
-                                            <SpeakIcon />
-                                          )
+                                        text={Locale.Chat.Actions.Retry}
+                                        icon={<ResetIcon />}
+                                        onClick={() => onResend(message)}
+                                      />
+
+                                      <ChatAction
+                                        text={Locale.Chat.Actions.Delete}
+                                        icon={<DeleteIcon />}
+                                        onClick={() =>
+                                          onDelete(message.id ?? i)
                                         }
+                                      />
+
+                                      <ChatAction
+                                        text={Locale.Chat.Actions.Pin}
+                                        icon={<PinIcon />}
+                                        onClick={() => onPinMessage(message)}
+                                      />
+                                      <ChatAction
+                                        text={Locale.Chat.Actions.Copy}
+                                        icon={<CopyIcon />}
                                         onClick={() =>
-                                          openaiSpeech(
+                                          copyToClipboard(
                                             getMessageTextContent(message),
                                           )
                                         }
                                       />
-                                    )}
-                                  </>
-                                )}
+                                      {config.ttsConfig.enable && (
+                                        <ChatAction
+                                          text={
+                                            speechStatus
+                                              ? Locale.Chat.Actions.StopSpeech
+                                              : Locale.Chat.Actions.Speech
+                                          }
+                                          icon={
+                                            speechStatus ? (
+                                              <SpeakStopIcon />
+                                            ) : (
+                                              <SpeakIcon />
+                                            )
+                                          }
+                                          onClick={() =>
+                                            openaiSpeech(
+                                              getMessageTextContent(message),
+                                            )
+                                          }
+                                        />
+                                      )}
+                                    </>
+                                  )}
+                                </div>
                               </div>
+                            )}
+                          </div>
+                          {message?.tools?.length == 0 && showTyping && (
+                            <div className={styles["chat-message-status"]}>
+                              {Locale.Chat.Typing}
                             </div>
                           )}
-                        </div>
-                        {message?.tools?.length == 0 && showTyping && (
-                          <div className={styles["chat-message-status"]}>
-                            {Locale.Chat.Typing}
-                          </div>
-                        )}
-                        {/*@ts-ignore*/}
-                        {message?.tools?.length > 0 && (
-                          <div className={styles["chat-message-tools"]}>
-                            {message?.tools?.map((tool) => (
+                          {/*@ts-ignore*/}
+                          {message?.tools?.length > 0 && (
+                            <div className={styles["chat-message-tools"]}>
+                              {message?.tools?.map((tool) => (
+                                <div
+                                  key={tool.id}
+                                  title={tool?.errorMsg}
+                                  className={styles["chat-message-tool"]}
+                                >
+                                  {tool.isError === false ? (
+                                    <ConfirmIcon />
+                                  ) : tool.isError === true ? (
+                                    <CloseIcon />
+                                  ) : (
+                                    <LoadingButtonIcon />
+                                  )}
+                                  <span>{tool?.function?.name}</span>
+                                </div>
+                              ))}
+                            </div>
+                          )}
+                          <div className={styles["chat-message-item"]}>
+                            <Markdown
+                              key={message.streaming ? "loading" : "done"}
+                              content={getMessageTextContent(message)}
+                              loading={
+                                (message.preview || message.streaming) &&
+                                message.content.length === 0 &&
+                                !isUser
+                              }
+                              //   onContextMenu={(e) => onRightClick(e, message)} // hard to use
+                              onDoubleClickCapture={() => {
+                                if (!isMobileScreen) return;
+                                setUserInput(getMessageTextContent(message));
+                              }}
+                              fontSize={fontSize}
+                              fontFamily={fontFamily}
+                              parentRef={scrollRef}
+                              defaultShow={i >= messages.length - 6}
+                            />
+                            {getMessageImages(message).length == 1 && (
+                              <img
+                                className={styles["chat-message-item-image"]}
+                                src={getMessageImages(message)[0]}
+                                alt=""
+                              />
+                            )}
+                            {getMessageImages(message).length > 1 && (
                               <div
-                                key={tool.id}
-                                title={tool?.errorMsg}
-                                className={styles["chat-message-tool"]}
+                                className={styles["chat-message-item-images"]}
+                                style={
+                                  {
+                                    "--image-count":
+                                      getMessageImages(message).length,
+                                  } as React.CSSProperties
+                                }
                               >
-                                {tool.isError === false ? (
-                                  <ConfirmIcon />
-                                ) : tool.isError === true ? (
-                                  <CloseIcon />
-                                ) : (
-                                  <LoadingButtonIcon />
+                                {getMessageImages(message).map(
+                                  (image, index) => {
+                                    return (
+                                      <img
+                                        className={
+                                          styles[
+                                            "chat-message-item-image-multi"
+                                          ]
+                                        }
+                                        key={index}
+                                        src={image}
+                                        alt=""
+                                      />
+                                    );
+                                  },
                                 )}
-                                <span>{tool?.function?.name}</span>
                               </div>
-                            ))}
+                            )}
                           </div>
-                        )}
-                        <div className={styles["chat-message-item"]}>
-                          <Markdown
-                            key={message.streaming ? "loading" : "done"}
-                            content={getMessageTextContent(message)}
-                            loading={
-                              (message.preview || message.streaming) &&
-                              message.content.length === 0 &&
-                              !isUser
-                            }
-                            //   onContextMenu={(e) => onRightClick(e, message)} // hard to use
-                            onDoubleClickCapture={() => {
-                              if (!isMobileScreen) return;
-                              setUserInput(getMessageTextContent(message));
-                            }}
-                            fontSize={fontSize}
-                            fontFamily={fontFamily}
-                            parentRef={scrollRef}
-                            defaultShow={i >= messages.length - 6}
-                          />
-                          {getMessageImages(message).length == 1 && (
-                            <img
-                              className={styles["chat-message-item-image"]}
-                              src={getMessageImages(message)[0]}
-                              alt=""
-                            />
-                          )}
-                          {getMessageImages(message).length > 1 && (
-                            <div
-                              className={styles["chat-message-item-images"]}
-                              style={
-                                {
-                                  "--image-count":
-                                    getMessageImages(message).length,
-                                } as React.CSSProperties
-                              }
-                            >
-                              {getMessageImages(message).map((image, index) => {
-                                return (
-                                  <img
-                                    className={
-                                      styles["chat-message-item-image-multi"]
-                                    }
-                                    key={index}
-                                    src={image}
-                                    alt=""
-                                  />
-                                );
-                              })}
+                          {message?.audio_url && (
+                            <div className={styles["chat-message-audio"]}>
+                              <audio src={message.audio_url} controls />
                             </div>
                           )}
-                        </div>
-                        {message?.audio_url && (
-                          <div className={styles["chat-message-audio"]}>
-                            <audio src={message.audio_url} controls />
-                          </div>
-                        )}
 
-                        <div className={styles["chat-message-action-date"]}>
-                          {isContext
-                            ? Locale.Chat.IsContext
-                            : message.date.toLocaleString()}
+                          <div className={styles["chat-message-action-date"]}>
+                            {isContext
+                              ? Locale.Chat.IsContext
+                              : message.date.toLocaleString()}
+                          </div>
                         </div>
                       </div>
-                    </div>
-                    {shouldShowClearContextDivider && <ClearContextDivider />}
-                  </Fragment>
-                );
-              })}
+                      {shouldShowClearContextDivider && <ClearContextDivider />}
+                    </Fragment>
+                  );
+                })}
             </div>
             <div className={styles["chat-input-panel"]}>
               <PromptHints

+ 25 - 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";
@@ -29,6 +29,7 @@ import { getClientConfig } from "../config/client";
 import { type ClientApi, getClientApi } from "../client/api";
 import { useAccessStore } from "../store";
 import clsx from "clsx";
+import { initializeMcpSystem, isMcpEnabled } from "../mcp/actions";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -74,6 +75,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 +201,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>
       </>
@@ -233,6 +242,20 @@ export function Home() {
   useEffect(() => {
     console.log("[Config] got config from build time", getClientConfig());
     useAccessStore.getState().fetch();
+
+    const initMcp = async () => {
+      try {
+        const enabled = await isMcpEnabled();
+        if (enabled) {
+          console.log("[MCP] initializing...");
+          await initializeMcpSystem();
+          console.log("[MCP] initialized");
+        }
+      } catch (err) {
+        console.error("[MCP] failed to initialize:", err);
+      }
+    };
+    initMcp();
   }, []);
 
   if (!useHasHydrated()) {

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

@@ -0,0 +1,657 @@
+@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;
+
+    .loading-container,
+    .empty-container {
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      min-height: 200px;
+      width: 100%;
+      background-color: var(--white);
+      border: var(--border-in-light);
+      border-radius: 10px;
+      animation: slide-in ease 0.3s;
+    }
+
+    .loading-text,
+    .empty-text {
+      font-size: 14px;
+      color: var(--black);
+      opacity: 0.5;
+      text-align: center;
+    }
+
+    .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 {
+      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;
+      }
+
+      &.loading {
+        position: relative;
+        &::after {
+          content: "";
+          position: absolute;
+          top: 0;
+          left: 0;
+          right: 0;
+          bottom: 0;
+          background: linear-gradient(
+            90deg,
+            transparent,
+            rgba(255, 255, 255, 0.2),
+            transparent
+          );
+          background-size: 200% 100%;
+          animation: loading-pulse 1.5s infinite;
+        }
+      }
+
+      .operation-status {
+        display: inline-flex;
+        align-items: center;
+        margin-left: 10px;
+        padding: 2px 8px;
+        border-radius: 4px;
+        font-size: 12px;
+        background-color: #16a34a;
+        color: #fff;
+        animation: pulse 1.5s infinite;
+
+        &[data-status="stopping"] {
+          background-color: #9ca3af;
+        }
+
+        &[data-status="starting"] {
+          background-color: #4ade80;
+        }
+
+        &[data-status="error"] {
+          background-color: #f87171;
+        }
+      }
+
+      .mcp-market-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: flex-start;
+        width: 100%;
+
+        .mcp-market-title {
+          flex-grow: 1;
+          margin-right: 20px;
+          max-width: calc(100% - 300px);
+        }
+
+        .mcp-market-name {
+          font-size: 14px;
+          font-weight: bold;
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          margin-bottom: 8px;
+
+          .server-status {
+            display: inline-flex;
+            align-items: center;
+            margin-left: 10px;
+            padding: 2px 8px;
+            border-radius: 4px;
+            font-size: 12px;
+            background-color: #22c55e;
+            color: #fff;
+
+            &.error {
+              background-color: #ef4444;
+            }
+
+            &.stopped {
+              background-color: #6b7280;
+            }
+
+            &.initializing {
+              background-color: #f59e0b;
+              animation: pulse 1.5s infinite;
+            }
+
+            .error-message {
+              margin-left: 4px;
+              font-size: 12px;
+            }
+          }
+        }
+
+        .repo-link {
+          color: var(--primary);
+          font-size: 12px;
+          display: inline-flex;
+          align-items: center;
+          gap: 4px;
+          text-decoration: none;
+          opacity: 0.8;
+          transition: opacity 0.2s;
+
+          &:hover {
+            opacity: 1;
+          }
+
+          svg {
+            width: 14px;
+            height: 14px;
+          }
+        }
+
+        .tags-container {
+          display: flex;
+          gap: 4px;
+          flex-wrap: wrap;
+          margin-bottom: 8px;
+        }
+
+        .tag {
+          background: var(--gray);
+          color: var(--black);
+          padding: 2px 6px;
+          border-radius: 4px;
+          font-size: 10px;
+          opacity: 0.8;
+        }
+
+        .mcp-market-info {
+          color: var(--black);
+          font-size: 12px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .mcp-market-actions {
+          display: flex;
+          gap: 12px;
+          align-items: flex-start;
+          flex-shrink: 0;
+          min-width: 180px;
+          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.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);
+        }
+      }
+
+      .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;
+      }
+    }
+  }
+
+  .tools-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;
+
+    .tool-item {
+      width: 100%;
+      box-sizing: border-box;
+
+      .tool-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%;
+      }
+
+      .tool-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;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+
+      .list-header {
+        margin-bottom: 0;
+
+        .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;
+        }
+      }
+    }
+  }
+}
+
+@keyframes loading-pulse {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+@keyframes pulse {
+  0% {
+    opacity: 0.6;
+  }
+  50% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0.6;
+  }
+}

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

@@ -0,0 +1,755 @@
+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 GithubIcon from "../icons/github.svg";
+import { List, ListItem, Modal, showToast } from "./ui-lib";
+import { useNavigate } from "react-router-dom";
+import { useEffect, useState } from "react";
+import {
+  addMcpServer,
+  getClientsStatus,
+  getClientTools,
+  getMcpConfigFromFile,
+  isMcpEnabled,
+  pauseMcpServer,
+  restartAllClients,
+  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";
+import { Path } from "../constant";
+
+interface ConfigProperty {
+  type: string;
+  description?: string;
+  required?: boolean;
+  minItems?: number;
+}
+
+export function McpMarketPage() {
+  const navigate = useNavigate();
+  const [mcpEnabled, setMcpEnabled] = useState(false);
+  const [searchText, setSearchText] = useState("");
+  const [userConfig, setUserConfig] = useState<Record<string, any>>({});
+  const [editingServerId, setEditingServerId] = useState<string | undefined>();
+  const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
+  const [viewingServerId, setViewingServerId] = useState<string | undefined>();
+  const [isLoading, setIsLoading] = useState(false);
+  const [config, setConfig] = useState<McpConfigData>();
+  const [clientStatuses, setClientStatuses] = useState<
+    Record<string, ServerStatusResponse>
+  >({});
+  const [loadingPresets, setLoadingPresets] = useState(true);
+  const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
+  const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
+    {},
+  );
+
+  // 检查 MCP 是否启用
+  useEffect(() => {
+    const checkMcpStatus = async () => {
+      const enabled = await isMcpEnabled();
+      setMcpEnabled(enabled);
+      if (!enabled) {
+        navigate(Path.Home);
+      }
+    };
+    checkMcpStatus();
+  }, [navigate]);
+
+  // 添加状态轮询
+  useEffect(() => {
+    if (!mcpEnabled || !config) return;
+
+    const updateStatuses = async () => {
+      const statuses = await getClientsStatus();
+      setClientStatuses(statuses);
+    };
+
+    // 立即执行一次
+    updateStatuses();
+    // 每 1000ms 轮询一次
+    const timer = setInterval(updateStatuses, 1000);
+
+    return () => clearInterval(timer);
+  }, [mcpEnabled, config]);
+
+  // 加载预设服务器
+  useEffect(() => {
+    const loadPresetServers = async () => {
+      if (!mcpEnabled) return;
+      try {
+        setLoadingPresets(true);
+        const response = await fetch("https://nextchat.club/mcp/list");
+        if (!response.ok) {
+          throw new Error("Failed to load preset servers");
+        }
+        const data = await response.json();
+        setPresetServers(data?.data ?? []);
+      } catch (error) {
+        console.error("Failed to load preset servers:", error);
+        showToast("Failed to load preset servers");
+      } finally {
+        setLoadingPresets(false);
+      }
+    };
+    loadPresetServers();
+  }, [mcpEnabled]);
+
+  // 加载初始状态
+  useEffect(() => {
+    const loadInitialState = async () => {
+      if (!mcpEnabled) return;
+      try {
+        setIsLoading(true);
+        const config = await getMcpConfigFromFile();
+        setConfig(config);
+
+        // 获取所有客户端的状态
+        const statuses = await getClientsStatus();
+        setClientStatuses(statuses);
+      } catch (error) {
+        console.error("Failed to load initial state:", error);
+        showToast("Failed to load initial state");
+      } finally {
+        setIsLoading(false);
+      }
+    };
+    loadInitialState();
+  }, [mcpEnabled]);
+
+  // 加载当前编辑服务器的配置
+  useEffect(() => {
+    if (!editingServerId || !config) return;
+    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") {
+            // For spread types, extract the array from args.
+            const startPos = mapping.position ?? 0;
+            userConfig[key] = currentConfig.args.slice(startPos);
+          } else if (mapping.type === "single") {
+            // For single types, get a single value
+            userConfig[key] = currentConfig.args[mapping.position ?? 0];
+          } else if (
+            mapping.type === "env" &&
+            mapping.key &&
+            currentConfig.env
+          ) {
+            // For env types, get values from environment variables
+            userConfig[key] = currentConfig.env[mapping.key];
+          }
+        });
+        setUserConfig(userConfig);
+      }
+    } else {
+      setUserConfig({});
+    }
+  }, [editingServerId, config, presetServers]);
+
+  if (!mcpEnabled) {
+    return null;
+  }
+
+  // 检查服务器是否已添加
+  const isServerAdded = (id: string) => {
+    return id in (config?.mcpServers ?? {});
+  };
+
+  // 保存服务器配置
+  const saveServerConfig = async () => {
+    const preset = presetServers.find((s) => s.id === editingServerId);
+    if (!preset || !preset.configSchema || !editingServerId) return;
+
+    const savingServerId = editingServerId;
+    setEditingServerId(undefined);
+
+    try {
+      updateLoadingState(savingServerId, "Updating configuration...");
+      // 构建服务器配置
+      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 = await addMcpServer(savingServerId, serverConfig);
+      setConfig(newConfig);
+      showToast("Server configuration updated successfully");
+    } catch (error) {
+      showToast(
+        error instanceof Error ? error.message : "Failed to save configuration",
+      );
+    } finally {
+      updateLoadingState(savingServerId, null);
+    }
+  };
+
+  // 获取服务器支持的 Tools
+  const loadTools = async (id: string) => {
+    try {
+      const result = await getClientTools(id);
+      if (result) {
+        setTools(result);
+      } else {
+        throw new Error("Failed to load tools");
+      }
+    } catch (error) {
+      showToast("Failed to load tools");
+      console.error(error);
+      setTools(null);
+    }
+  };
+
+  // 更新加载状态的辅助函数
+  const updateLoadingState = (id: string, message: string | null) => {
+    setLoadingStates((prev) => {
+      if (message === null) {
+        const { [id]: _, ...rest } = prev;
+        return rest;
+      }
+      return { ...prev, [id]: message };
+    });
+  };
+
+  // 修改添加服务器函数
+  const addServer = async (preset: PresetServer) => {
+    if (!preset.configurable) {
+      try {
+        const serverId = preset.id;
+        updateLoadingState(serverId, "Creating MCP client...");
+
+        const serverConfig: ServerConfig = {
+          command: preset.command,
+          args: [...preset.baseArgs],
+        };
+        const newConfig = await addMcpServer(preset.id, serverConfig);
+        setConfig(newConfig);
+
+        // 更新状态
+        const statuses = await getClientsStatus();
+        setClientStatuses(statuses);
+      } finally {
+        updateLoadingState(preset.id, null);
+      }
+    } else {
+      // 如果需要配置,打开配置对话框
+      setEditingServerId(preset.id);
+      setUserConfig({});
+    }
+  };
+
+  // 修改暂停服务器函数
+  const pauseServer = async (id: string) => {
+    try {
+      updateLoadingState(id, "Stopping server...");
+      const newConfig = await pauseMcpServer(id);
+      setConfig(newConfig);
+      showToast("Server stopped successfully");
+    } catch (error) {
+      showToast("Failed to stop server");
+      console.error(error);
+    } finally {
+      updateLoadingState(id, null);
+    }
+  };
+
+  // Restart server
+  const restartServer = async (id: string) => {
+    try {
+      updateLoadingState(id, "Starting server...");
+      await resumeMcpServer(id);
+    } catch (error) {
+      showToast(
+        error instanceof Error
+          ? error.message
+          : "Failed to start server, please check logs",
+      );
+      console.error(error);
+    } finally {
+      updateLoadingState(id, null);
+    }
+  };
+
+  // Restart all clients
+  const handleRestartAll = async () => {
+    try {
+      updateLoadingState("all", "Restarting all servers...");
+      const newConfig = await restartAllClients();
+      setConfig(newConfig);
+      showToast("Restarting all clients");
+    } catch (error) {
+      showToast("Failed to restart clients");
+      console.error(error);
+    } finally {
+      updateLoadingState("all", null);
+    }
+  };
+
+  // Render configuration form
+  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] || [];
+          const itemLabel = (prop as any).itemLabel || key;
+          const addButtonText =
+            (prop as any).addButtonText || `Add ${itemLabel}`;
+
+          return (
+            <ListItem
+              key={key}
+              title={key}
+              subTitle={prop.description}
+              vertical
+            >
+              <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={`${itemLabel} ${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={addButtonText}
+                  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}>
+              <input
+                aria-label={key}
+                type="text"
+                value={currentValue}
+                placeholder={`Enter ${key}`}
+                onChange={(e) => {
+                  setUserConfig({ ...userConfig, [key]: e.target.value });
+                }}
+              />
+            </ListItem>
+          );
+        }
+        return null;
+      },
+    );
+  };
+
+  const checkServerStatus = (clientId: string) => {
+    return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
+  };
+
+  const getServerStatusDisplay = (clientId: string) => {
+    const status = checkServerStatus(clientId);
+
+    const statusMap = {
+      undefined: null, // 未配置/未找到不显示
+      // 添加初始化状态
+      initializing: (
+        <span className={clsx(styles["server-status"], styles["initializing"])}>
+          Initializing
+        </span>
+      ),
+      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];
+  };
+
+  // Get the type of operation status
+  const getOperationStatusType = (message: string) => {
+    if (message.toLowerCase().includes("stopping")) return "stopping";
+    if (message.toLowerCase().includes("starting")) return "starting";
+    if (message.toLowerCase().includes("error")) return "error";
+    return "default";
+  };
+
+  // 渲染服务器列表
+  const renderServerList = () => {
+    if (loadingPresets) {
+      return (
+        <div className={styles["loading-container"]}>
+          <div className={styles["loading-text"]}>
+            Loading preset server list...
+          </div>
+        </div>
+      );
+    }
+
+    if (!Array.isArray(presetServers) || presetServers.length === 0) {
+      return (
+        <div className={styles["empty-container"]}>
+          <div className={styles["empty-text"]}>No servers available</div>
+        </div>
+      );
+    }
+
+    return presetServers
+      .filter((server) => {
+        if (searchText.length === 0) return true;
+        const searchLower = searchText.toLowerCase();
+        return (
+          server.name.toLowerCase().includes(searchLower) ||
+          server.description.toLowerCase().includes(searchLower) ||
+          server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
+        );
+      })
+      .sort((a, b) => {
+        const aStatus = checkServerStatus(a.id).status;
+        const bStatus = checkServerStatus(b.id).status;
+        const aLoading = loadingStates[a.id];
+        const bLoading = loadingStates[b.id];
+
+        // 定义状态优先级
+        const statusPriority: Record<string, number> = {
+          error: 0, // Highest priority for error status
+          active: 1, // Second for active
+          initializing: 2, // Initializing
+          starting: 3, // Starting
+          stopping: 4, // Stopping
+          paused: 5, // Paused
+          undefined: 6, // Lowest priority for undefined
+        };
+
+        // Get actual status (including loading status)
+        const getEffectiveStatus = (status: string, loading?: string) => {
+          if (loading) {
+            const operationType = getOperationStatusType(loading);
+            return operationType === "default" ? status : operationType;
+          }
+
+          if (status === "initializing" && !loading) {
+            return "active";
+          }
+
+          return status;
+        };
+
+        const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
+        const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
+
+        // 首先按状态排序
+        if (aEffectiveStatus !== bEffectiveStatus) {
+          return (
+            (statusPriority[aEffectiveStatus] ?? 6) -
+            (statusPriority[bEffectiveStatus] ?? 6)
+          );
+        }
+
+        // Sort by name when statuses are the same
+        return a.name.localeCompare(b.name);
+      })
+      .map((server) => (
+        <div
+          className={clsx(styles["mcp-market-item"], {
+            [styles["loading"]]: loadingStates[server.id],
+          })}
+          key={server.id}
+        >
+          <div className={styles["mcp-market-header"]}>
+            <div className={styles["mcp-market-title"]}>
+              <div className={styles["mcp-market-name"]}>
+                {server.name}
+                {loadingStates[server.id] && (
+                  <span
+                    className={styles["operation-status"]}
+                    data-status={getOperationStatusType(
+                      loadingStates[server.id],
+                    )}
+                  >
+                    {loadingStates[server.id]}
+                  </span>
+                )}
+                {!loadingStates[server.id] && getServerStatusDisplay(server.id)}
+                {server.repo && (
+                  <a
+                    href={server.repo}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className={styles["repo-link"]}
+                    title="Open repository"
+                  >
+                    <GithubIcon />
+                  </a>
+                )}
+              </div>
+              <div className={styles["tags-container"]}>
+                {server.tags.map((tag, index) => (
+                  <span key={index} className={styles["tag"]}>
+                    {tag}
+                  </span>
+                ))}
+              </div>
+              <div
+                className={clsx(styles["mcp-market-info"], "one-line")}
+                title={server.description}
+              >
+                {server.description}
+              </div>
+            </div>
+            <div className={styles["mcp-market-actions"]}>
+              {isServerAdded(server.id) ? (
+                <>
+                  {server.configurable && (
+                    <IconButton
+                      icon={<EditIcon />}
+                      text="Configure"
+                      onClick={() => setEditingServerId(server.id)}
+                      disabled={isLoading}
+                    />
+                  )}
+                  {checkServerStatus(server.id).status === "paused" ? (
+                    <>
+                      <IconButton
+                        icon={<PlayIcon />}
+                        text="Start"
+                        onClick={() => restartServer(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"
+                  onClick={() => addServer(server)}
+                  disabled={isLoading}
+                />
+              )}
+            </div>
+          </div>
+        </div>
+      ));
+  };
+
+  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
+              {loadingStates["all"] && (
+                <span className={styles["loading-indicator"]}>
+                  {loadingStates["all"]}
+                </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={handleRestartAll}
+                text="Restart All"
+                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"]}>{renderServerList()}</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["tools-list"]}>
+                {isLoading ? (
+                  <div>Loading...</div>
+                ) : tools?.tools ? (
+                  tools.tools.map(
+                    (tool: ListToolsResponse["tools"], index: number) => (
+                      <div key={index} className={styles["tool-item"]}>
+                        <div className={styles["tool-name"]}>{tool.name}</div>
+                        <div className={styles["tool-description"]}>
+                          {tool.description}
+                        </div>
+                      </div>
+                    ),
+                  )
+                ) : (
+                  <div>No tools available</div>
+                )}
+              </div>
+            </Modal>
+          </div>
+        )}
+      </div>
+    </ErrorBoundary>
+  );
+}

+ 27 - 2
app/components/sidebar.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
+import React, { Fragment, useEffect, useMemo, useRef, useState } from "react";
 
 import styles from "./home.module.scss";
 
@@ -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";
 
@@ -28,8 +29,9 @@ import {
 import { Link, useNavigate } from "react-router-dom";
 import { isIOS, useMobileScreen } from "../utils";
 import dynamic from "next/dynamic";
-import { showConfirm, Selector } from "./ui-lib";
+import { Selector, showConfirm } from "./ui-lib";
 import clsx from "clsx";
+import { isMcpEnabled } from "../mcp/actions";
 
 const DISCOVERY = [
   { name: Locale.Plugin.Name, path: Path.Plugins },
@@ -133,6 +135,7 @@ export function useDragSideBar() {
     shouldNarrow,
   };
 }
+
 export function SideBarContainer(props: {
   children: React.ReactNode;
   onDragStart: (e: MouseEvent) => void;
@@ -228,6 +231,17 @@ export function SideBar(props: { className?: string }) {
   const navigate = useNavigate();
   const config = useAppConfig();
   const chatStore = useChatStore();
+  const [mcpEnabled, setMcpEnabled] = useState(false);
+
+  useEffect(() => {
+    // 检查 MCP 是否启用
+    const checkMcpStatus = async () => {
+      const enabled = await isMcpEnabled();
+      setMcpEnabled(enabled);
+      console.log("[SideBar] MCP enabled:", enabled);
+    };
+    checkMcpStatus();
+  }, []);
 
   return (
     <SideBarContainer
@@ -255,6 +269,17 @@ export function SideBar(props: { className?: string }) {
             }}
             shadow
           />
+          {mcpEnabled && (
+            <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}

+ 3 - 0
app/config/server.ts

@@ -86,6 +86,8 @@ declare global {
 
       // custom template for preprocessing user input
       DEFAULT_INPUT_TEMPLATE?: string;
+
+      ENABLE_MCP?: string; // enable mcp functionality
     }
   }
 }
@@ -253,5 +255,6 @@ export const getServerSideConfig = () => {
     defaultModel,
     visionModels,
     allowedWebDavEndpoints,
+    enableMcp: !!process.env.ENABLE_MCP,
   };
 };

+ 126 - 0
app/constant.ts

@@ -49,6 +49,7 @@ export enum Path {
   SdNew = "/sd-new",
   Artifacts = "/artifacts",
   SearchChat = "/search-chat",
+  McpMarket = "/mcp-market",
 }
 
 export enum ApiPath {
@@ -90,6 +91,7 @@ export enum StoreKey {
   Update = "chat-update",
   Sync = "sync",
   SdList = "sd-list",
+  Mcp = "mcp-store",
 }
 
 export const DEFAULT_SIDEBAR_WIDTH = 300;
@@ -265,6 +267,130 @@ Latex inline: \\(x^2\\)
 Latex block: $$e=mc^2$$
 `;
 
+export const MCP_TOOLS_TEMPLATE = `
+[clientId]
+{{ clientId }}
+[tools]
+{{ tools }}
+`;
+
+export const MCP_SYSTEM_TEMPLATE = `
+You are an AI assistant with access to system tools. Your role is to help users by combining natural language understanding with tool operations when needed.
+
+1. AVAILABLE TOOLS:
+{{ MCP_TOOLS }}
+
+2. WHEN TO USE TOOLS:
+   - ALWAYS USE TOOLS when they can help answer user questions
+   - DO NOT just describe what you could do - TAKE ACTION immediately
+   - If you're not sure whether to use a tool, USE IT
+   - Common triggers for tool use:
+     * Questions about files or directories
+     * Requests to check, list, or manipulate system resources
+     * Any query that can be answered with available tools
+
+3. HOW TO USE TOOLS:
+   A. Tool Call Format:
+      - Use markdown code blocks with format: \`\`\`json:mcp:{clientId}\`\`\`
+      - Always include:
+        * method: "tools/call"(Only this method is supported)
+        * params: 
+          - name: must match an available primitive name
+          - arguments: required parameters for the primitive
+
+   B. Response Format:
+      - Tool responses will come as user messages
+      - Format: \`\`\`json:mcp-response:{clientId}\`\`\`
+      - Wait for response before making another tool call
+
+   C. Important Rules:
+      - Only use tools/call method
+      - Only ONE tool call per message
+      - ALWAYS TAKE ACTION instead of just describing what you could do
+      - Include the correct clientId in code block language tag
+      - Verify arguments match the primitive's requirements
+
+4. INTERACTION FLOW:
+   A. When user makes a request:
+      - IMMEDIATELY use appropriate tool if available
+      - DO NOT ask if user wants you to use the tool
+      - DO NOT just describe what you could do
+   B. After receiving tool response:
+      - Explain results clearly
+      - Take next appropriate action if needed
+   C. If tools fail:
+      - Explain the error
+      - Try alternative approach immediately
+
+5. EXAMPLE INTERACTION:
+
+  good example:
+
+   \`\`\`json:mcp:filesystem
+   {
+     "method": "tools/call",
+     "params": {
+       "name": "list_allowed_directories",
+       "arguments": {}
+     }
+   }
+   \`\`\`"
+
+
+  \`\`\`json:mcp-response:filesystem
+  {
+  "method": "tools/call",
+  "params": {
+    "name": "write_file",
+    "arguments": {
+      "path": "/Users/river/dev/nextchat/test/joke.txt",
+      "content": "为什么数学书总是感到忧伤?因为它有太多的问题。"
+    }
+  }
+  }
+\`\`\`
+
+   follwing is the wrong! mcp json example:
+
+   \`\`\`json:mcp:filesystem
+   {
+      "method": "write_file",
+      "params": {
+        "path": "NextChat_Information.txt",
+        "content": "1"
+    }
+   }
+   \`\`\`
+
+   This is wrong because the method is not tools/call.
+   
+   \`\`\`{
+  "method": "search_repositories",
+  "params": {
+    "query": "2oeee"
+  }
+}
+   \`\`\`
+
+   This is wrong because the method is not tools/call.!!!!!!!!!!!
+
+   the right format is:
+   \`\`\`json:mcp:filesystem
+   {
+     "method": "tools/call",
+     "params": {
+       "name": "search_repositories",
+       "arguments": {
+         "query": "2oeee"
+       }
+     }
+   }
+   \`\`\`
+   
+   please follow the format strictly ONLY use tools/call method!!!!!!!!!!!
+   
+`;
+
 export const SUMMARIZE_MODEL = "gpt-4o-mini";
 export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
 

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

+ 1 - 0
app/icons/tool.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M10.155 3.247c-.519.396-1.129 1.004-2.012 1.887s-1.49 1.493-1.887 2.012c-.383.502-.497.83-.497 1.14s.114.638.497 1.14c.397.52 1.004 1.13 1.887 2.012l4.419 4.419c.883.883 1.493 1.49 2.012 1.887c.502.383.83.497 1.14.497s.638-.114 1.14-.497c.519-.396 1.129-1.004 2.012-1.887s1.49-1.493 1.887-2.012c.383-.503.497-.83.497-1.14s-.114-.638-.497-1.14c-.396-.52-1.004-1.13-1.887-2.012l-4.419-4.419c-.883-.883-1.493-1.49-2.012-1.887c-.502-.383-.83-.497-1.14-.497s-.637.114-1.14.497m-.91-1.192c.636-.485 1.28-.805 2.05-.805s1.414.32 2.05.805c.609.464 1.29 1.145 2.125 1.98l.244.245c.239-.238.451-.44.685-.574a2.31 2.31 0 0 1 2.312 0c.267.154.505.393.787.675l.06.06l.061.061c.282.282.521.52.675.787a2.31 2.31 0 0 1 0 2.312c-.135.234-.336.446-.574.685l.245.244c.835.836 1.516 1.516 1.98 2.125c.485.636.805 1.28.805 2.05s-.32 1.414-.805 2.05c-.464.608-1.145 1.289-1.98 2.124l-.077.077c-.835.835-1.516 1.516-2.125 1.98c-.635.485-1.28.805-2.05.805c-.768 0-1.413-.32-2.049-.805c-.609-.464-1.29-1.145-2.125-1.98l-.244-.245l-4.993 4.994l-.06.06c-.282.282-.52.521-.787.675a2.31 2.31 0 0 1-2.312 0c-.267-.154-.505-.393-.787-.675l-.06-.06l-.061-.061c-.282-.282-.521-.52-.675-.787a2.31 2.31 0 0 1 0-2.312c.154-.266.393-.505.675-.786l.06-.061l4.994-4.993l-.245-.244c-.835-.836-1.516-1.516-1.98-2.125c-.485-.636-.805-1.28-.805-2.05s.32-1.414.805-2.05c.464-.608 1.145-1.289 1.98-2.124l.077-.077c.835-.835 1.516-1.516 2.125-1.98m-.896 11.71L3.356 18.76c-.376.376-.456.465-.497.536a.81.81 0 0 0 0 .812c.04.072.12.16.497.537c.377.376.466.456.537.497a.81.81 0 0 0 .812 0c.07-.04.16-.12.536-.497l4.994-4.993zm10.31-6.54c.24-.243.302-.314.336-.374a.81.81 0 0 0 0-.812c-.041-.071-.12-.16-.497-.537c-.377-.376-.466-.456-.537-.497a.81.81 0 0 0-.812 0c-.06.034-.131.096-.374.336z" clip-rule="evenodd"/></svg>

+ 3 - 2
app/layout.tsx

@@ -5,9 +5,8 @@ import "./styles/highlight.scss";
 import { getClientConfig } from "./config/client";
 import type { Metadata, Viewport } from "next";
 import { SpeedInsights } from "@vercel/speed-insights/next";
-import { getServerSideConfig } from "./config/server";
 import { GoogleTagManager, GoogleAnalytics } from "@next/third-parties/google";
-const serverConfig = getServerSideConfig();
+import { getServerSideConfig } from "./config/server";
 
 export const metadata: Metadata = {
   title: "NextChat",
@@ -33,6 +32,8 @@ export default function RootLayout({
 }: {
   children: React.ReactNode;
 }) {
+  const serverConfig = getServerSideConfig();
+
   return (
     <html lang="en">
       <head>

+ 3 - 0
app/locales/cn.ts

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

+ 3 - 0
app/locales/en.ts

@@ -647,6 +647,9 @@ const en: LocaleType = {
   Discovery: {
     Name: "Discovery",
   },
+  Mcp: {
+    Name: "MCP",
+  },
   FineTuned: {
     Sysmessage: "You are an assistant that",
   },

+ 383 - 0
app/mcp/actions.ts

@@ -0,0 +1,383 @@
+"use server";
+import {
+  createClient,
+  executeRequest,
+  listTools,
+  removeClient,
+} from "./client";
+import { MCPClientLogger } from "./logger";
+import {
+  DEFAULT_MCP_CONFIG,
+  McpClientData,
+  McpConfigData,
+  McpRequestMessage,
+  ServerConfig,
+  ServerStatusResponse,
+} from "./types";
+import fs from "fs/promises";
+import path from "path";
+import { getServerSideConfig } from "../config/server";
+
+const logger = new MCPClientLogger("MCP Actions");
+const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json");
+
+const clientsMap = new Map<string, McpClientData>();
+
+// 获取客户端状态
+export async function getClientsStatus(): Promise<
+  Record<string, ServerStatusResponse>
+> {
+  const config = await getMcpConfigFromFile();
+  const result: Record<string, ServerStatusResponse> = {};
+
+  for (const clientId of Object.keys(config.mcpServers)) {
+    const status = clientsMap.get(clientId);
+    const serverConfig = config.mcpServers[clientId];
+
+    if (!serverConfig) {
+      result[clientId] = { status: "undefined", errorMsg: null };
+      continue;
+    }
+
+    if (serverConfig.status === "paused") {
+      result[clientId] = { status: "paused", errorMsg: null };
+      continue;
+    }
+
+    if (!status) {
+      result[clientId] = { status: "undefined", errorMsg: null };
+      continue;
+    }
+
+    if (
+      status.client === null &&
+      status.tools === null &&
+      status.errorMsg === null
+    ) {
+      result[clientId] = { status: "initializing", errorMsg: null };
+      continue;
+    }
+
+    if (status.errorMsg) {
+      result[clientId] = { status: "error", errorMsg: status.errorMsg };
+      continue;
+    }
+
+    if (status.client) {
+      result[clientId] = { status: "active", errorMsg: null };
+      continue;
+    }
+
+    result[clientId] = { status: "error", errorMsg: "Client not found" };
+  }
+
+  return result;
+}
+
+// 获取客户端工具
+export async function getClientTools(clientId: string) {
+  return clientsMap.get(clientId)?.tools ?? null;
+}
+
+// 获取可用客户端数量
+export async function getAvailableClientsCount() {
+  let count = 0;
+  clientsMap.forEach((map) => !map.errorMsg && count++);
+  return count;
+}
+
+// 获取所有客户端工具
+export async function getAllTools() {
+  const result = [];
+  for (const [clientId, status] of clientsMap.entries()) {
+    result.push({
+      clientId,
+      tools: status.tools,
+    });
+  }
+  return result;
+}
+
+// 初始化单个客户端
+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}]...`);
+
+  // 先设置初始化状态
+  clientsMap.set(clientId, {
+    client: null,
+    tools: null,
+    errorMsg: null, // null 表示正在初始化
+  });
+
+  // 异步初始化
+  createClient(clientId, serverConfig)
+    .then(async (client) => {
+      const tools = await listTools(client);
+      logger.info(
+        `Supported tools for [${clientId}]: ${JSON.stringify(tools, null, 2)}`,
+      );
+      clientsMap.set(clientId, { client, tools, errorMsg: null });
+      logger.success(`Client [${clientId}] initialized successfully`);
+    })
+    .catch((error) => {
+      clientsMap.set(clientId, {
+        client: null,
+        tools: null,
+        errorMsg: error instanceof Error ? error.message : String(error),
+      });
+      logger.error(`Failed to initialize client [${clientId}]: ${error}`);
+    });
+}
+
+// 初始化系统
+export async function initializeMcpSystem() {
+  logger.info("MCP Actions starting...");
+  try {
+    // 检查是否已有活跃的客户端
+    if (clientsMap.size > 0) {
+      logger.info("MCP system already initialized, skipping...");
+      return;
+    }
+
+    const config = await getMcpConfigFromFile();
+    // 初始化所有客户端
+    for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
+      await initializeSingleClient(clientId, serverConfig);
+    }
+    return config;
+  } catch (error) {
+    logger.error(`Failed to initialize MCP system: ${error}`);
+    throw error;
+  }
+}
+
+// 添加服务器
+export async function addMcpServer(clientId: string, config: ServerConfig) {
+  try {
+    const currentConfig = await getMcpConfigFromFile();
+    const isNewServer = !(clientId in currentConfig.mcpServers);
+
+    // 如果是新服务器,设置默认状态为 active
+    if (isNewServer && !config.status) {
+      config.status = "active";
+    }
+
+    const newConfig = {
+      ...currentConfig,
+      mcpServers: {
+        ...currentConfig.mcpServers,
+        [clientId]: config,
+      },
+    };
+    await updateMcpConfig(newConfig);
+
+    // 只有新服务器或状态为 active 的服务器才初始化
+    if (isNewServer || config.status === "active") {
+      await initializeSingleClient(clientId, config);
+    }
+
+    return newConfig;
+  } catch (error) {
+    logger.error(`Failed to add server [${clientId}]: ${error}`);
+    throw error;
+  }
+}
+
+// 暂停服务器
+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",
+        },
+      },
+    };
+    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<void> {
+  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);
+    } 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}`);
+      throw error;
+    }
+  } catch (error) {
+    logger.error(`Failed to resume server [${clientId}]: ${error}`);
+    throw error;
+  }
+}
+
+// 移除服务器
+export async function removeMcpServer(clientId: string) {
+  try {
+    const currentConfig = await getMcpConfigFromFile();
+    const { [clientId]: _, ...rest } = currentConfig.mcpServers;
+    const newConfig = {
+      ...currentConfig,
+      mcpServers: rest,
+    };
+    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 remove server [${clientId}]: ${error}`);
+    throw error;
+  }
+}
+
+// 重启所有客户端
+export async function restartAllClients() {
+  logger.info("Restarting all clients...");
+  try {
+    // 关闭所有客户端
+    for (const client of clientsMap.values()) {
+      if (client.client) {
+        await removeClient(client.client);
+      }
+    }
+
+    // 清空状态
+    clientsMap.clear();
+
+    // 重新初始化
+    const config = await getMcpConfigFromFile();
+    for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
+      await initializeSingleClient(clientId, serverConfig);
+    }
+    return config;
+  } catch (error) {
+    logger.error(`Failed to restart clients: ${error}`);
+    throw error;
+  }
+}
+
+// 执行 MCP 请求
+export async function executeMcpAction(
+  clientId: string,
+  request: McpRequestMessage,
+) {
+  try {
+    const client = clientsMap.get(clientId);
+    if (!client?.client) {
+      throw new Error(`Client ${clientId} not found`);
+    }
+    logger.info(`Executing request for [${clientId}]`);
+    return await executeRequest(client.client, request);
+  } catch (error) {
+    logger.error(`Failed to execute request for [${clientId}]: ${error}`);
+    throw error;
+  }
+}
+
+// 获取 MCP 配置文件
+export async function getMcpConfigFromFile(): Promise<McpConfigData> {
+  try {
+    const configStr = await fs.readFile(CONFIG_PATH, "utf-8");
+    return JSON.parse(configStr);
+  } catch (error) {
+    logger.error(`Failed to load MCP config, using default config: ${error}`);
+    return DEFAULT_MCP_CONFIG;
+  }
+}
+
+// 更新 MCP 配置文件
+async function updateMcpConfig(config: McpConfigData): Promise<void> {
+  try {
+    await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
+  } catch (error) {
+    throw error;
+  }
+}
+
+// 检查 MCP 是否启用
+export async function isMcpEnabled() {
+  try {
+    const serverConfig = getServerSideConfig();
+    return serverConfig.enableMcp;
+  } catch (error) {
+    logger.error(`Failed to check MCP status: ${error}`);
+    return false;
+  }
+}

+ 55 - 0
app/mcp/client.ts

@@ -0,0 +1,55 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+import { MCPClientLogger } from "./logger";
+import { ListToolsResponse, McpRequestMessage, ServerConfig } from "./types";
+import { z } from "zod";
+
+const logger = new MCPClientLogger();
+
+export async function createClient(
+  id: string,
+  config: ServerConfig,
+): Promise<Client> {
+  logger.info(`Creating client for ${id}...`);
+
+  const transport = new StdioClientTransport({
+    command: config.command,
+    args: config.args,
+    env: {
+      ...Object.fromEntries(
+        Object.entries(process.env)
+          .filter(([_, v]) => v !== undefined)
+          .map(([k, v]) => [k, v as string]),
+      ),
+      ...(config.env || {}),
+    },
+  });
+
+  const client = new Client(
+    {
+      name: `nextchat-mcp-client-${id}`,
+      version: "1.0.0",
+    },
+    {
+      capabilities: {},
+    },
+  );
+  await client.connect(transport);
+  return client;
+}
+
+export async function removeClient(client: Client) {
+  logger.info(`Removing client...`);
+  await client.close();
+}
+
+export async function listTools(client: Client): Promise<ListToolsResponse> {
+  return client.listTools();
+}
+
+export async function executeRequest(
+  client: Client,
+  request: McpRequestMessage,
+) {
+  return client.request(request, z.any());
+}

+ 65 - 0
app/mcp/logger.ts

@@ -0,0 +1,65 @@
+// ANSI color codes for terminal output
+const colors = {
+  reset: "\x1b[0m",
+  bright: "\x1b[1m",
+  dim: "\x1b[2m",
+  green: "\x1b[32m",
+  yellow: "\x1b[33m",
+  red: "\x1b[31m",
+  blue: "\x1b[34m",
+};
+
+export class MCPClientLogger {
+  private readonly prefix: string;
+  private readonly debugMode: boolean;
+
+  constructor(
+    prefix: string = "NextChat MCP Client",
+    debugMode: boolean = false,
+  ) {
+    this.prefix = prefix;
+    this.debugMode = debugMode;
+  }
+
+  info(message: any) {
+    this.print(colors.blue, message);
+  }
+
+  success(message: any) {
+    this.print(colors.green, message);
+  }
+
+  error(message: any) {
+    this.print(colors.red, message);
+  }
+
+  warn(message: any) {
+    this.print(colors.yellow, message);
+  }
+
+  debug(message: any) {
+    if (this.debugMode) {
+      this.print(colors.dim, message);
+    }
+  }
+
+  /**
+   * Format message to string, if message is object, convert to JSON string
+   */
+  private formatMessage(message: any): string {
+    return typeof message === "object"
+      ? JSON.stringify(message, null, 2)
+      : message;
+  }
+
+  /**
+   * Print formatted message to console
+   */
+  private print(color: string, message: any) {
+    const formattedMessage = this.formatMessage(message);
+    const logMessage = `${color}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`;
+
+    // 只使用 console.log,这样日志会显示在 Tauri 的终端中
+    console.log(logMessage);
+  }
+}

+ 180 - 0
app/mcp/types.ts

@@ -0,0 +1,180 @@
+// ref: https://spec.modelcontextprotocol.io/specification/basic/messages/
+
+import { z } from "zod";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+
+export interface McpRequestMessage {
+  jsonrpc?: "2.0";
+  id?: string | number;
+  method: "tools/call" | string;
+  params?: {
+    [key: string]: unknown;
+  };
+}
+
+export const McpRequestMessageSchema: z.ZodType<McpRequestMessage> = z.object({
+  jsonrpc: z.literal("2.0").optional(),
+  id: z.union([z.string(), z.number()]).optional(),
+  method: z.string(),
+  params: z.record(z.unknown()).optional(),
+});
+
+export interface McpResponseMessage {
+  jsonrpc?: "2.0";
+  id?: string | number;
+  result?: {
+    [key: string]: unknown;
+  };
+  error?: {
+    code: number;
+    message: string;
+    data?: unknown;
+  };
+}
+
+export const McpResponseMessageSchema: z.ZodType<McpResponseMessage> = z.object(
+  {
+    jsonrpc: z.literal("2.0").optional(),
+    id: z.union([z.string(), z.number()]).optional(),
+    result: z.record(z.unknown()).optional(),
+    error: z
+      .object({
+        code: z.number(),
+        message: z.string(),
+        data: z.unknown().optional(),
+      })
+      .optional(),
+  },
+);
+
+export interface McpNotifications {
+  jsonrpc?: "2.0";
+  method: string;
+  params?: {
+    [key: string]: unknown;
+  };
+}
+
+export const McpNotificationsSchema: z.ZodType<McpNotifications> = z.object({
+  jsonrpc: z.literal("2.0").optional(),
+  method: z.string(),
+  params: z.record(z.unknown()).optional(),
+});
+
+////////////
+// Next Chat
+////////////
+export interface ListToolsResponse {
+  tools: {
+    name?: string;
+    description?: string;
+    inputSchema?: object;
+    [key: string]: any;
+  };
+}
+
+export type McpClientData =
+  | McpActiveClient
+  | McpErrorClient
+  | McpInitializingClient;
+
+interface McpInitializingClient {
+  client: null;
+  tools: null;
+  errorMsg: null;
+}
+
+interface McpActiveClient {
+  client: Client;
+  tools: ListToolsResponse;
+  errorMsg: null;
+}
+
+interface McpErrorClient {
+  client: null;
+  tools: null;
+  errorMsg: string;
+}
+
+// 服务器状态类型
+export type ServerStatus =
+  | "undefined"
+  | "active"
+  | "paused"
+  | "error"
+  | "initializing";
+
+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 {
+  // MCP Server 的配置
+  mcpServers: Record<string, ServerConfig>;
+}
+
+export const DEFAULT_MCP_CONFIG: McpConfigData = {
+  mcpServers: {},
+};
+
+export interface ArgsMapping {
+  // 参数映射的类型
+  type: "spread" | "single" | "env";
+
+  // 参数映射的位置
+  position?: number;
+
+  // 参数映射的 key
+  key?: string;
+}
+
+export interface PresetServer {
+  // MCP Server 的唯一标识,作为最终配置文件 Json 的 key
+  id: string;
+
+  // MCP Server 的显示名称
+  name: string;
+
+  // MCP Server 的描述
+  description: string;
+
+  // MCP Server 的仓库地址
+  repo: string;
+
+  // MCP Server 的标签
+  tags: string[];
+
+  // MCP Server 的命令
+  command: string;
+
+  // MCP Server 的参数
+  baseArgs: string[];
+
+  // MCP Server 是否需要配置
+  configurable: boolean;
+
+  // MCP Server 的配置 schema
+  configSchema?: {
+    properties: Record<
+      string,
+      {
+        type: string;
+        description?: string;
+        required?: boolean;
+        minItems?: number;
+      }
+    >;
+  };
+
+  // MCP Server 的参数映射
+  argsMapping?: Record<string, ArgsMapping>;
+}

+ 11 - 0
app/mcp/utils.ts

@@ -0,0 +1,11 @@
+export function isMcpJson(content: string) {
+  return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
+}
+
+export function extractMcpJson(content: string) {
+  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 - 2
app/page.tsx

@@ -1,7 +1,5 @@
 import { Analytics } from "@vercel/analytics/react";
-
 import { Home } from "./components/home";
-
 import { getServerSideConfig } from "./config/server";
 
 const serverConfig = getServerSideConfig();

+ 96 - 21
app/store/chat.ts

@@ -1,4 +1,9 @@
-import { getMessageTextContent, trimTopic } from "../utils";
+import {
+  getMessageTextContent,
+  isDalle3,
+  safeLocalStorage,
+  trimTopic,
+} from "../utils";
 
 import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
 import { nanoid } from "nanoid";
@@ -14,14 +19,15 @@ import {
   DEFAULT_INPUT_TEMPLATE,
   DEFAULT_MODELS,
   DEFAULT_SYSTEM_TEMPLATE,
+  GEMINI_SUMMARIZE_MODEL,
   KnowledgeCutOffDate,
+  MCP_SYSTEM_TEMPLATE,
+  MCP_TOOLS_TEMPLATE,
+  ServiceProvider,
   StoreKey,
   SUMMARIZE_MODEL,
-  GEMINI_SUMMARIZE_MODEL,
-  ServiceProvider,
 } from "../constant";
 import Locale, { getLang } from "../locales";
-import { isDalle3, safeLocalStorage } from "../utils";
 import { prettyObject } from "../utils/format";
 import { createPersistStore } from "../utils/store";
 import { estimateTokenLength } from "../utils/token";
@@ -29,6 +35,8 @@ import { ModelConfig, ModelType, useAppConfig } from "./config";
 import { useAccessStore } from "./access";
 import { collectModelsWithDefaultModel } from "../utils/model";
 import { createEmptyMask, Mask } from "./mask";
+import { executeMcpAction, getAllTools } from "../mcp/actions";
+import { extractMcpJson, isMcpJson } from "../mcp/utils";
 
 const localStorage = safeLocalStorage();
 
@@ -53,6 +61,7 @@ export type ChatMessage = RequestMessage & {
   model?: ModelType;
   tools?: ChatMessageTool[];
   audio_url?: string;
+  isMcpResponse?: boolean;
 };
 
 export function createMessage(override: Partial<ChatMessage>): ChatMessage {
@@ -189,6 +198,27 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) {
   return output;
 }
 
+async function getMcpSystemPrompt(): Promise<string> {
+  const tools = await getAllTools();
+
+  let toolsStr = "";
+
+  tools.forEach((i) => {
+    // error client has no tools
+    if (!i.tools) return;
+
+    toolsStr += MCP_TOOLS_TEMPLATE.replace(
+      "{{ clientId }}",
+      i.clientId,
+    ).replace(
+      "{{ tools }}",
+      i.tools.tools.map((p: object) => JSON.stringify(p, null, 2)).join("\n"),
+    );
+  });
+
+  return MCP_SYSTEM_TEMPLATE.replace("{{ MCP_TOOLS }}", toolsStr);
+}
+
 const DEFAULT_CHAT_STATE = {
   sessions: [createEmptySession()],
   currentSessionIndex: 0,
@@ -362,24 +392,30 @@ export const useChatStore = createPersistStore(
           session.messages = session.messages.concat();
           session.lastUpdate = Date.now();
         });
+
         get().updateStat(message, targetSession);
+
+        get().checkMcpJson(message);
+
         get().summarizeSession(false, targetSession);
       },
 
-      async onUserInput(content: string, attachImages?: string[]) {
+      async onUserInput(
+        content: string,
+        attachImages?: string[],
+        isMcpResponse?: boolean,
+      ) {
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
 
-        const userContent = fillTemplateWith(content, modelConfig);
-        console.log("[User Input] after template: ", userContent);
-
-        let mContent: string | MultimodalContent[] = userContent;
+        // MCP Response no need to fill template
+        let mContent: string | MultimodalContent[] = isMcpResponse
+          ? content
+          : fillTemplateWith(content, modelConfig);
 
-        if (attachImages && attachImages.length > 0) {
+        if (!isMcpResponse && attachImages && attachImages.length > 0) {
           mContent = [
-            ...(userContent
-              ? [{ type: "text" as const, text: userContent }]
-              : []),
+            ...(content ? [{ type: "text" as const, text: content }] : []),
             ...attachImages.map((url) => ({
               type: "image_url" as const,
               image_url: { url },
@@ -390,6 +426,7 @@ export const useChatStore = createPersistStore(
         let userMessage: ChatMessage = createMessage({
           role: "user",
           content: mContent,
+          isMcpResponse,
         });
 
         const botMessage: ChatMessage = createMessage({
@@ -399,7 +436,7 @@ export const useChatStore = createPersistStore(
         });
 
         // get recent messages
-        const recentMessages = get().getMessagesWithMemory();
+        const recentMessages = await get().getMessagesWithMemory();
         const sendMessages = recentMessages.concat(userMessage);
         const messageIndex = session.messages.length + 1;
 
@@ -429,7 +466,7 @@ export const useChatStore = createPersistStore(
               session.messages = session.messages.concat();
             });
           },
-          onFinish(message) {
+          async onFinish(message) {
             botMessage.streaming = false;
             if (message) {
               botMessage.content = message;
@@ -498,7 +535,7 @@ export const useChatStore = createPersistStore(
         }
       },
 
-      getMessagesWithMemory() {
+      async getMessagesWithMemory() {
         const session = get().currentSession();
         const modelConfig = session.mask.modelConfig;
         const clearContextIndex = session.clearContextIndex ?? 0;
@@ -514,18 +551,26 @@ export const useChatStore = createPersistStore(
           (session.mask.modelConfig.model.startsWith("gpt-") ||
             session.mask.modelConfig.model.startsWith("chatgpt-"));
 
+        const mcpSystemPrompt = await getMcpSystemPrompt();
+
         var systemPrompts: ChatMessage[] = [];
         systemPrompts = shouldInjectSystemPrompts
           ? [
               createMessage({
                 role: "system",
-                content: fillTemplateWith("", {
-                  ...modelConfig,
-                  template: DEFAULT_SYSTEM_TEMPLATE,
-                }),
+                content:
+                  fillTemplateWith("", {
+                    ...modelConfig,
+                    template: DEFAULT_SYSTEM_TEMPLATE,
+                  }) + mcpSystemPrompt,
               }),
             ]
-          : [];
+          : [
+              createMessage({
+                role: "system",
+                content: mcpSystemPrompt,
+              }),
+            ];
         if (shouldInjectSystemPrompts) {
           console.log(
             "[Global System Prompt] ",
@@ -768,6 +813,36 @@ export const useChatStore = createPersistStore(
           lastInput,
         });
       },
+
+      /** check if the message contains MCP JSON and execute the MCP action */
+      checkMcpJson(message: ChatMessage) {
+        const content = getMessageTextContent(message);
+        if (isMcpJson(content)) {
+          try {
+            const mcpRequest = extractMcpJson(content);
+            if (mcpRequest) {
+              console.debug("[MCP Request]", mcpRequest);
+
+              executeMcpAction(mcpRequest.clientId, mcpRequest.mcp)
+                .then((result) => {
+                  console.log("[MCP Response]", result);
+                  const mcpResponse =
+                    typeof result === "object"
+                      ? JSON.stringify(result)
+                      : String(result);
+                  get().onUserInput(
+                    `\`\`\`json:mcp-response:${mcpRequest.clientId}\n${mcpResponse}\n\`\`\``,
+                    [],
+                    true,
+                  );
+                })
+                .catch((error) => showToast("MCP execution failed", error));
+            }
+          } catch (error) {
+            console.error("[Check MCP JSON]", error);
+          }
+        }
+      },
     };
 
     return methods;

+ 5 - 3
next.config.mjs

@@ -71,8 +71,10 @@ if (mode !== "export") {
       // },
       {
         // https://{resource_name}.openai.azure.com/openai/deployments/{deploy_name}/chat/completions
-        source: "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*",
-        destination: "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*",
+        source:
+          "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*",
+        destination:
+          "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*",
       },
       {
         source: "/api/proxy/google/:path*",
@@ -99,7 +101,7 @@ if (mode !== "export") {
         destination: "https://dashscope.aliyuncs.com/api/:path*",
       },
     ];
-    
+
     return {
       beforeFiles: ret,
     };

+ 6 - 3
package.json

@@ -13,6 +13,7 @@
     "export:dev": "concurrently -r \"yarn mask:watch\"  \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"",
     "app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"",
     "app:build": "yarn mask && yarn tauri build",
+    "app:clear": "yarn tauri dev",
     "prompts": "node ./scripts/fetch-prompts.mjs",
     "prepare": "husky install",
     "proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev",
@@ -22,6 +23,7 @@
   "dependencies": {
     "@fortaine/fetch-event-source": "^3.0.6",
     "@hello-pangea/dnd": "^16.5.0",
+    "@modelcontextprotocol/sdk": "^1.0.4",
     "@next/third-parties": "^14.1.0",
     "@svgr/webpack": "^6.5.1",
     "@vercel/analytics": "^0.1.11",
@@ -49,14 +51,15 @@
     "remark-breaks": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
+    "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz",
     "sass": "^1.59.2",
     "spark-md5": "^3.0.2",
     "use-debounce": "^9.0.4",
-    "zustand": "^4.3.8",
-    "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz"
+    "zod": "^3.24.1",
+    "zustand": "^4.3.8"
   },
   "devDependencies": {
-    "@tauri-apps/api": "^1.6.0",
+    "@tauri-apps/api": "^2.1.1",
     "@tauri-apps/cli": "1.5.11",
     "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.6.3",

+ 2 - 2
tsconfig.json

@@ -1,6 +1,6 @@
 {
   "compilerOptions": {
-    "target": "ES2015",
+    "target": "ES2022",
     "lib": ["dom", "dom.iterable", "esnext"],
     "allowJs": true,
     "skipLibCheck": true,
@@ -23,6 +23,6 @@
       "@/*": ["./*"]
     }
   },
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"],
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
   "exclude": ["node_modules"]
 }

+ 79 - 14
yarn.lock

@@ -1797,6 +1797,15 @@
     "@jridgewell/resolve-uri" "3.1.0"
     "@jridgewell/sourcemap-codec" "1.4.14"
 
+"@modelcontextprotocol/sdk@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.0.4.tgz#34ad1edd3db7dd7154e782312dfb29d2d0c11d21"
+  integrity sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow==
+  dependencies:
+    content-type "^1.0.5"
+    raw-body "^3.0.0"
+    zod "^3.23.8"
+
 "@next/env@14.1.1":
   version "14.1.1"
   resolved "https://registry.yarnpkg.com/@next/env/-/env-14.1.1.tgz#80150a8440eb0022a73ba353c6088d419b908bac"
@@ -2029,10 +2038,10 @@
   dependencies:
     tslib "^2.4.0"
 
-"@tauri-apps/api@^1.6.0":
-  version "1.6.0"
-  resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-1.6.0.tgz#745b7e4e26782c3b2ad9510d558fa5bb2cf29186"
-  integrity sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==
+"@tauri-apps/api@^2.1.1":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.1.1.tgz#77d4ddb683d31072de4e6a47c8613d9db011652b"
+  integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==
 
 "@tauri-apps/cli-darwin-arm64@1.5.11":
   version "1.5.11"
@@ -3039,6 +3048,11 @@ busboy@1.6.0:
   dependencies:
     streamsearch "^1.1.0"
 
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
 call-bind@^1.0.0, call-bind@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -3062,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"
@@ -3285,6 +3294,11 @@ concurrently@^8.2.2:
     tree-kill "^1.2.2"
     yargs "^17.7.2"
 
+content-type@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
+
 convert-source-map@^1.7.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
@@ -3849,6 +3863,11 @@ delayed-stream@~1.0.0:
   resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
 
+depd@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
 dequal@^2.0.0, dequal@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
@@ -5007,6 +5026,17 @@ html-to-image@^1.11.11:
   resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea"
   integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==
 
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
 http-proxy-agent@^5.0.0:
   version "5.0.0"
   resolved "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@@ -5095,7 +5125,7 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2:
+inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -7138,6 +7168,16 @@ randombytes@^2.1.0:
   dependencies:
     safe-buffer "^5.1.0"
 
+raw-body@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f"
+  integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.6.3"
+    unpipe "1.0.0"
+
 react-dom@^18.2.0:
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
@@ -7569,6 +7609,11 @@ serialize-javascript@^6.0.1:
   dependencies:
     randombytes "^2.1.0"
 
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -7699,6 +7744,11 @@ stack-utils@^2.0.3:
   dependencies:
     escape-string-regexp "^2.0.0"
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 stop-iteration-iterator@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
@@ -7977,6 +8027,11 @@ to-regex-range@^5.0.1:
   dependencies:
     is-number "^7.0.0"
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
 tough-cookie@^4.1.2:
   version "4.1.4"
   resolved "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36"
@@ -8219,6 +8274,11 @@ universalify@^0.2.0:
   resolved "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
   integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==
 
+unpipe@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
 update-browserslist-db@^1.0.10:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3"
@@ -8572,6 +8632,11 @@ yocto-queue@^0.1.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
 
+zod@^3.23.8, zod@^3.24.1:
+  version "3.24.1"
+  resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee"
+  integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==
+
 zustand@^4.3.8:
   version "4.3.8"
   resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.8.tgz#37113df8e9e1421b0be1b2dca02b49b76210e7c4"