From 7d51bfd42e0f60a328abed353ab1ef717b6f3ba8 Mon Sep 17 00:00:00 2001 From: Kadxy <2230318258@qq.com> Date: Thu, 9 Jan 2025 19:51:01 +0800 Subject: [PATCH] feat: MCP market --- app/components/home.tsx | 12 +- app/components/mcp-market.module.scss | 612 ++++++++++++++++++++++++++ app/components/mcp-market.tsx | 564 ++++++++++++++++++++++++ app/components/sidebar.tsx | 10 + app/constant.ts | 1 + app/icons/mcp.svg | 15 + app/locales/cn.ts | 3 + app/mcp/actions.ts | 132 +++++- app/mcp/mcp_config.json | 24 +- app/mcp/preset-server.json | 206 +++++++++ app/mcp/types.ts | 38 ++ app/mcp/utils.ts | 6 +- next.config.mjs | 1 - yarn.lock | 13 +- 14 files changed, 1607 insertions(+), 30 deletions(-) create mode 100644 app/components/mcp-market.module.scss create mode 100644 app/components/mcp-market.tsx create mode 100644 app/icons/mcp.svg create mode 100644 app/mcp/preset-server.json diff --git a/app/components/home.tsx b/app/components/home.tsx index 5da49037885..32c5b4ac67a 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -2,7 +2,7 @@ require("../polyfill"); -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import styles from "./home.module.scss"; import BotIcon from "../icons/bot.svg"; @@ -18,8 +18,8 @@ import { getISOLang, getLang } from "../locales"; import { HashRouter as Router, - Routes, Route, + Routes, useLocation, } from "react-router-dom"; import { SideBar } from "./sidebar"; @@ -74,6 +74,13 @@ const Sd = dynamic(async () => (await import("./sd")).Sd, { loading: () => , }); +const McpMarketPage = dynamic( + async () => (await import("./mcp-market")).McpMarketPage, + { + loading: () => , + }, +); + export function useSwitchTheme() { const config = useAppConfig(); @@ -193,6 +200,7 @@ function Screen() { } /> } /> } /> + } /> diff --git a/app/components/mcp-market.module.scss b/app/components/mcp-market.module.scss new file mode 100644 index 00000000000..5e4b6e9b015 --- /dev/null +++ b/app/components/mcp-market.module.scss @@ -0,0 +1,612 @@ +@import "../styles/animation.scss"; + +.mcp-market-page { + height: 100%; + display: flex; + flex-direction: column; + + .loading-indicator { + font-size: 12px; + color: var(--primary); + margin-left: 8px; + font-weight: normal; + opacity: 0.8; + } + + .mcp-market-page-body { + padding: 20px; + overflow-y: auto; + + .mcp-market-filter { + width: 100%; + max-width: 100%; + margin-bottom: 20px; + animation: slide-in ease 0.3s; + height: 40px; + display: flex; + + .search-bar { + flex-grow: 1; + max-width: 100%; + min-width: 0; + } + } + + .server-list { + display: flex; + flex-direction: column; + gap: 1px; + } + + .mcp-market-item { + display: flex; + justify-content: space-between; + padding: 20px; + border: var(--border-in-light); + animation: slide-in ease 0.3s; + background-color: var(--white); + transition: all 0.3s ease; + + &.disabled { + opacity: 0.7; + pointer-events: none; + } + + &:not(:last-child) { + border-bottom: 0; + } + + &:first-child { + border-top-left-radius: 10px; + border-top-right-radius: 10px; + } + + &:last-child { + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; + } + + .mcp-market-header { + display: flex; + align-items: center; + + .mcp-market-title { + .mcp-market-name { + font-size: 14px; + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; + + .server-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + margin-left: 8px; + background-color: #10b981; + color: white; + + &.error { + background-color: #ef4444; + } + + &.waiting { + background-color: #f59e0b; + } + + .error-message { + font-size: 11px; + opacity: 0.9; + margin-left: 4px; + } + } + } + + .mcp-market-info { + font-size: 12px; + color: var(--black-50); + margin-top: 4px; + } + } + } + + .mcp-market-actions { + display: flex; + gap: 8px; + align-items: center; + + :global(.icon-button) { + transition: all 0.3s ease; + border: 1px solid transparent; + + &:hover { + transform: translateY(-1px); + filter: brightness(1.1); + } + + &.action-primary { + background-color: var(--primary); + color: white; + + svg { + filter: brightness(2); + } + + &:hover { + background-color: var(--primary); + border-color: var(--primary); + } + } + + &.action-warning { + background-color: var(--warning); + color: white; + + svg { + filter: brightness(2); + } + + &:hover { + background-color: var(--warning); + border-color: var(--warning); + } + } + + &.action-danger { + background-color: transparent; + color: var(--danger); + border-color: var(--danger); + + &:hover { + background-color: var(--danger); + color: white; + + svg { + filter: brightness(2); + } + } + } + + &.action-error { + color: #ef4444 !important; + border-color: #ef4444 !important; + } + } + } + + @media screen and (max-width: 600px) { + flex-direction: column; + gap: 10px; + + .mcp-market-actions { + justify-content: flex-end; + } + } + } + } + + .array-input { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 16px; + border: 1px solid var(--gray-200); + border-radius: 10px; + background-color: var(--white); + + .array-input-item { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 0; + + input { + width: 100%; + padding: 8px 12px; + background-color: var(--gray-50); + border-radius: 6px; + transition: all 0.3s ease; + font-size: 13px; + border: 1px solid var(--gray-200); + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + &:focus { + background-color: var(--white); + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300); + } + } + + :global(.icon-button) { + width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + background-color: transparent; + border: 1px solid var(--gray-200); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + svg { + width: 16px; + height: 16px; + opacity: 0.7; + } + } + } + + :global(.icon-button.add-path-button) { + width: 100%; + background-color: var(--primary); + color: white; + padding: 8px 12px; + border-radius: 6px; + transition: all 0.3s ease; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + border: none; + height: 36px; + + &:hover { + background-color: var(--primary-dark); + } + + svg { + width: 16px; + height: 16px; + margin-right: 4px; + filter: brightness(2); + } + } + } + + .path-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + + .path-item { + display: flex; + gap: 10px; + width: 100%; + + input { + flex: 1; + width: 100%; + max-width: 100%; + padding: 10px; + border: var(--border-in-light); + border-radius: 10px; + box-sizing: border-box; + font-size: 14px; + background-color: var(--white); + color: var(--black); + + &:hover { + border-color: var(--gray-300); + } + + &:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300) !important; + opacity: 1; + } + } + + .browse-button { + padding: 8px; + border: var(--border-in-light); + border-radius: 10px; + background-color: transparent; + color: var(--black-50); + + &:hover { + border-color: var(--primary); + color: var(--primary); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + + .delete-button { + padding: 8px; + border: var(--border-in-light); + border-radius: 10px; + background-color: transparent; + color: var(--black-50); + + &:hover { + border-color: var(--danger); + color: var(--danger); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + + .file-input { + display: none; + } + } + + .add-button { + align-self: flex-start; + display: flex; + align-items: center; + gap: 5px; + padding: 8px 12px; + background-color: transparent; + border: var(--border-in-light); + border-radius: 10px; + color: var(--black); + font-size: 12px; + margin-top: 5px; + + &:hover { + border-color: var(--primary); + color: var(--primary); + background-color: transparent; + } + + svg { + width: 16px; + height: 16px; + } + } + } + + .config-section { + width: 100%; + + .config-header { + margin-bottom: 12px; + + .config-title { + font-size: 14px; + font-weight: 600; + color: var(--black); + text-transform: capitalize; + } + + .config-description { + font-size: 12px; + color: var(--gray-500); + margin-top: 4px; + } + } + + .array-input { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 16px; + border: 1px solid var(--gray-200); + border-radius: 10px; + background-color: var(--white); + + .array-input-item { + display: flex; + gap: 8px; + align-items: center; + width: 100%; + padding: 0; + + input { + width: 100%; + padding: 8px 12px; + background-color: var(--gray-50); + border-radius: 6px; + transition: all 0.3s ease; + font-size: 13px; + border: 1px solid var(--gray-200); + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + &:focus { + background-color: var(--white); + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300); + } + } + + :global(.icon-button) { + width: 32px; + height: 32px; + padding: 0; + border-radius: 6px; + background-color: transparent; + border: 1px solid var(--gray-200); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-300); + } + + svg { + width: 16px; + height: 16px; + opacity: 0.7; + } + } + } + + :global(.icon-button.add-path-button) { + width: 100%; + background-color: var(--primary); + color: white; + padding: 8px 12px; + border-radius: 6px; + transition: all 0.3s ease; + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + border: none; + height: 36px; + + &:hover { + background-color: var(--primary-dark); + } + + svg { + width: 16px; + height: 16px; + margin-right: 4px; + filter: brightness(2); + } + } + } + } + + .input-item { + width: 100%; + + input { + width: 100%; + padding: 10px; + border: var(--border-in-light); + border-radius: 10px; + box-sizing: border-box; + font-size: 14px; + background-color: var(--white); + color: var(--black); + + &:hover { + border-color: var(--gray-300); + } + + &:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px var(--primary-10); + } + + &::placeholder { + color: var(--gray-300) !important; + opacity: 1; + } + } + } + + .primitives-list { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + padding: 20px; + max-width: 100%; + overflow-x: hidden; + word-break: break-word; + box-sizing: border-box; + + .primitive-item { + width: 100%; + box-sizing: border-box; + + .primitive-name { + font-size: 14px; + font-weight: 600; + color: var(--black); + margin-bottom: 8px; + padding-left: 12px; + border-left: 3px solid var(--primary); + box-sizing: border-box; + width: 100%; + } + + .primitive-description { + font-size: 13px; + color: var(--gray-500); + line-height: 1.6; + padding-left: 15px; + box-sizing: border-box; + width: 100%; + } + } + } + + :global { + .modal-content { + margin-top: 20px; + max-width: 100%; + overflow-x: hidden; + } + + .list { + padding: 10px; + margin-bottom: 10px; + background-color: var(--white); + } + + .list-item { + border: none; + background-color: transparent; + border-radius: 10px; + padding: 10px; + margin-bottom: 10px; + + .list-header { + margin-bottom: 10px; + + .list-title { + font-size: 14px; + font-weight: bold; + text-transform: capitalize; + color: var(--black); + } + + .list-sub-title { + font-size: 12px; + color: var(--gray-500); + margin-top: 4px; + } + } + } + } +} diff --git a/app/components/mcp-market.tsx b/app/components/mcp-market.tsx new file mode 100644 index 00000000000..5f0723e397c --- /dev/null +++ b/app/components/mcp-market.tsx @@ -0,0 +1,564 @@ +import { IconButton } from "./button"; +import { ErrorBoundary } from "./error"; +import styles from "./mcp-market.module.scss"; +import EditIcon from "../icons/edit.svg"; +import AddIcon from "../icons/add.svg"; +import CloseIcon from "../icons/close.svg"; +import DeleteIcon from "../icons/delete.svg"; +import RestartIcon from "../icons/reload.svg"; +import EyeIcon from "../icons/eye.svg"; +import { List, ListItem, Modal, showToast } from "./ui-lib"; +import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import presetServersJson from "../mcp/preset-server.json"; +const presetServers = presetServersJson as PresetServer[]; +import { + getMcpConfig, + updateMcpConfig, + getClientPrimitives, + restartAllClients, + reinitializeMcpClients, + getClientErrors, +} from "../mcp/actions"; +import { McpConfig, PresetServer, ServerConfig } from "../mcp/types"; +import clsx from "clsx"; + +interface ConfigProperty { + type: string; + description?: string; + required?: boolean; + minItems?: number; +} + +export function McpMarketPage() { + const navigate = useNavigate(); + const [searchText, setSearchText] = useState(""); + const [config, setConfig] = useState({ mcpServers: {} }); + const [editingServerId, setEditingServerId] = useState(); + const [viewingServerId, setViewingServerId] = useState(); + const [primitives, setPrimitives] = useState([]); + const [userConfig, setUserConfig] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [clientErrors, setClientErrors] = useState< + Record + >({}); + + // 更新服务器状态 + const updateServerStatus = async () => { + await reinitializeMcpClients(); + const errors = await getClientErrors(); + setClientErrors(errors); + }; + + // 初始加载配置 + useEffect(() => { + const init = async () => { + try { + setIsLoading(true); + const data = await getMcpConfig(); + setConfig(data); + await updateServerStatus(); + } catch (error) { + showToast("Failed to load configuration"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + init(); + }, []); + + // 保存配置 + const saveConfig = async (newConfig: McpConfig) => { + try { + setIsLoading(true); + await updateMcpConfig(newConfig); + setConfig(newConfig); + await updateServerStatus(); + showToast("Configuration saved successfully"); + } catch (error) { + showToast("Failed to save configuration"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + // 检查服务器是否已添加 + const isServerAdded = (id: string) => { + return id in config.mcpServers; + }; + + // 加载当前编辑服务器的配置 + useEffect(() => { + if (editingServerId) { + const currentConfig = config.mcpServers[editingServerId]; + if (currentConfig) { + // 从当前配置中提取用户配置 + const preset = presetServers.find((s) => s.id === editingServerId); + if (preset?.configSchema) { + const userConfig: Record = {}; + Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { + if (mapping.type === "spread") { + // 对于 spread 类型,从 args 中提取数组 + const startPos = mapping.position ?? 0; + userConfig[key] = currentConfig.args.slice(startPos); + } else if (mapping.type === "single") { + // 对于 single 类型,获取单个值 + userConfig[key] = currentConfig.args[mapping.position ?? 0]; + } else if ( + mapping.type === "env" && + mapping.key && + currentConfig.env + ) { + // 对于 env 类型,从环境变量中获取值 + userConfig[key] = currentConfig.env[mapping.key]; + } + }); + setUserConfig(userConfig); + } + } else { + setUserConfig({}); + } + } + }, [editingServerId, config.mcpServers]); + + // 保存服务器配置 + const saveServerConfig = async () => { + const preset = presetServers.find((s) => s.id === editingServerId); + if (!preset || !preset.configSchema || !editingServerId) return; + + try { + // 构建服务器配置 + const args = [...preset.baseArgs]; + const env: Record = {}; + + Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { + const value = userConfig[key]; + if (mapping.type === "spread" && Array.isArray(value)) { + const pos = mapping.position ?? 0; + args.splice(pos, 0, ...value); + } else if ( + mapping.type === "single" && + mapping.position !== undefined + ) { + args[mapping.position] = value; + } else if ( + mapping.type === "env" && + mapping.key && + typeof value === "string" + ) { + env[mapping.key] = value; + } + }); + + const serverConfig: ServerConfig = { + command: preset.command, + args, + ...(Object.keys(env).length > 0 ? { env } : {}), + }; + + // 更新配置 + const newConfig = { + ...config, + mcpServers: { + ...config.mcpServers, + [editingServerId]: serverConfig, + }, + }; + + await saveConfig(newConfig); + setEditingServerId(undefined); + showToast("Server configuration saved successfully"); + } catch (error) { + showToast( + error instanceof Error ? error.message : "Failed to save configuration", + ); + } + }; + + // 渲染配置表单 + const renderConfigForm = () => { + const preset = presetServers.find((s) => s.id === editingServerId); + if (!preset?.configSchema) return null; + + return Object.entries(preset.configSchema.properties).map( + ([key, prop]: [string, ConfigProperty]) => { + if (prop.type === "array") { + const currentValue = userConfig[key as keyof typeof userConfig] || []; + return ( + +
+ {(currentValue as string[]).map( + (value: string, index: number) => ( +
+ { + const newValue = [...currentValue] as string[]; + newValue[index] = e.target.value; + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> + } + className={styles["delete-button"]} + onClick={() => { + const newValue = [...currentValue] as string[]; + newValue.splice(index, 1); + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> +
+ ), + )} + } + text="Add Path" + className={styles["add-button"]} + bordered + onClick={() => { + const newValue = [...currentValue, ""] as string[]; + setUserConfig({ ...userConfig, [key]: newValue }); + }} + /> +
+
+ ); + } else if (prop.type === "string") { + const currentValue = userConfig[key as keyof typeof userConfig] || ""; + return ( + +
+ { + setUserConfig({ ...userConfig, [key]: e.target.value }); + }} + /> +
+
+ ); + } + return null; + }, + ); + }; + + // 获取服务器的 Primitives + const loadPrimitives = async (id: string) => { + try { + setIsLoading(true); + const result = await getClientPrimitives(id); + if (result) { + setPrimitives(result); + } else { + showToast("Server is not running"); + setPrimitives([]); + } + } catch (error) { + showToast("Failed to load primitives"); + console.error(error); + setPrimitives([]); + } finally { + setIsLoading(false); + } + }; + + // 重启所有客户端 + const handleRestart = async () => { + try { + setIsLoading(true); + await restartAllClients(); + await updateServerStatus(); + showToast("All clients restarted successfully"); + } catch (error) { + showToast("Failed to restart clients"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + // 添加服务器 + const addServer = async (preset: PresetServer) => { + if (!preset.configurable) { + try { + setIsLoading(true); + showToast("Creating MCP client..."); + // 如果服务器不需要配置,直接添加 + const serverConfig: ServerConfig = { + command: preset.command, + args: [...preset.baseArgs], + }; + const newConfig = { + ...config, + mcpServers: { + ...config.mcpServers, + [preset.id]: serverConfig, + }, + }; + await saveConfig(newConfig); + } finally { + setIsLoading(false); + } + } else { + // 如果需要配置,打开配置对话框 + setEditingServerId(preset.id); + setUserConfig({}); + } + }; + + // 移除服务器 + const removeServer = async (id: string) => { + try { + setIsLoading(true); + const { [id]: _, ...rest } = config.mcpServers; + const newConfig = { + ...config, + mcpServers: rest, + }; + await saveConfig(newConfig); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+
+
+
+ MCP Market + {isLoading && ( + Loading... + )} +
+
+ {Object.keys(config.mcpServers).length} servers configured +
+
+ +
+
+ } + bordered + onClick={handleRestart} + text="Restart" + disabled={isLoading} + /> +
+
+ } + bordered + onClick={() => navigate(-1)} + disabled={isLoading} + /> +
+
+
+ +
+
+ setSearchText(e.currentTarget.value)} + /> +
+ +
+ {presetServers + .filter( + (m) => + searchText.length === 0 || + m.name.toLowerCase().includes(searchText.toLowerCase()) || + m.description + .toLowerCase() + .includes(searchText.toLowerCase()), + ) + .sort((a, b) => { + const aAdded = isServerAdded(a.id); + const bAdded = isServerAdded(b.id); + const aError = clientErrors[a.id] !== null; + const bError = clientErrors[b.id] !== null; + + if (aAdded !== bAdded) { + return aAdded ? -1 : 1; + } + if (aAdded && bAdded) { + if (aError !== bError) { + return aError ? -1 : 1; + } + } + return 0; + }) + .map((server) => ( +
+
+
+
+ {server.name} + {isServerAdded(server.id) && ( + + {clientErrors[server.id] === null + ? "Active" + : "Error"} + {clientErrors[server.id] && ( + + : {clientErrors[server.id]} + + )} + + )} +
+
+ {server.description} +
+
+
+
+ {isServerAdded(server.id) ? ( + <> + {server.configurable && ( + } + text="Configure" + className={clsx({ + [styles["action-error"]]: + clientErrors[server.id] !== null, + })} + onClick={() => setEditingServerId(server.id)} + disabled={isLoading} + /> + )} + {isServerAdded(server.id) && ( + } + text="Detail" + onClick={async () => { + if (clientErrors[server.id] !== null) { + showToast("Server is not running"); + return; + } + setViewingServerId(server.id); + await loadPrimitives(server.id); + }} + disabled={isLoading} + /> + )} + } + text="Remove" + className={styles["action-danger"]} + onClick={() => removeServer(server.id)} + disabled={isLoading} + /> + + ) : ( + } + text="Add" + className={styles["action-primary"]} + onClick={() => addServer(server)} + disabled={isLoading} + /> + )} +
+
+ ))} +
+
+ + {editingServerId && ( +
+ !isLoading && setEditingServerId(undefined)} + actions={[ + setEditingServerId(undefined)} + bordered + disabled={isLoading} + />, + , + ]} + > + {renderConfigForm()} + +
+ )} + + {viewingServerId && ( +
+ setViewingServerId(undefined)} + actions={[ + setViewingServerId(undefined)} + bordered + />, + ]} + > +
+ {isLoading ? ( +
Loading...
+ ) : primitives.filter((p) => p.type === "tool").length > 0 ? ( + primitives + .filter((p) => p.type === "tool") + .map((primitive, index) => ( +
+
+ {primitive.value.name} +
+ {primitive.value.description && ( +
+ {primitive.value.description} +
+ )} +
+ )) + ) : ( +
No tools available
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index a5e33b15ea3..84b0973bd93 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -9,6 +9,7 @@ import ChatGptIcon from "../icons/chatgpt.svg"; import AddIcon from "../icons/add.svg"; import DeleteIcon from "../icons/delete.svg"; import MaskIcon from "../icons/mask.svg"; +import McpIcon from "../icons/mcp.svg"; import DragIcon from "../icons/drag.svg"; import DiscoveryIcon from "../icons/discovery.svg"; @@ -250,6 +251,15 @@ export function SideBar(props: { className?: string }) { }} shadow /> + } + text={shouldNarrow ? undefined : Locale.Mcp.Name} + className={styles["sidebar-bar-button"]} + onClick={() => { + navigate(Path.McpMarket, { state: { fromHome: true } }); + }} + shadow + /> } text={shouldNarrow ? undefined : Locale.Discovery.Name} diff --git a/app/constant.ts b/app/constant.ts index 9d15b5fa11d..3c0ff6213aa 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -47,6 +47,7 @@ export enum Path { SdNew = "/sd-new", Artifacts = "/artifacts", SearchChat = "/search-chat", + McpMarket = "/mcp-market", } export enum ApiPath { diff --git a/app/icons/mcp.svg b/app/icons/mcp.svg new file mode 100644 index 00000000000..aaf0bbc7431 --- /dev/null +++ b/app/icons/mcp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 47be019a809..bd8b530603e 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -626,6 +626,9 @@ const cn = { Discovery: { Name: "发现", }, + Mcp: { + Name: "MCP", + }, FineTuned: { Sysmessage: "你是一个助手", }, diff --git a/app/mcp/actions.ts b/app/mcp/actions.ts index ad07bb4288a..f9a6afc86e9 100644 --- a/app/mcp/actions.ts +++ b/app/mcp/actions.ts @@ -7,15 +7,16 @@ import { Primitive, } from "./client"; import { MCPClientLogger } from "./logger"; -import conf from "./mcp_config.json"; -import { McpRequestMessage } from "./types"; +import { McpRequestMessage, McpConfig, ServerConfig } from "./types"; +import fs from "fs/promises"; +import path from "path"; const logger = new MCPClientLogger("MCP Actions"); // Use Map to store all clients const clientsMap = new Map< string, - { client: Client; primitives: Primitive[] } + { client: Client | null; primitives: Primitive[]; errorMsg: string | null } >(); // Whether initialized @@ -24,27 +25,76 @@ let initialized = false; // Store failed clients let errorClients: string[] = []; +const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json"); + +// 获取 MCP 配置 +export async function getMcpConfig(): Promise { + try { + const configStr = await fs.readFile(CONFIG_PATH, "utf-8"); + return JSON.parse(configStr); + } catch (error) { + console.error("Failed to read MCP config:", error); + return { mcpServers: {} }; + } +} + +// 更新 MCP 配置 +export async function updateMcpConfig(config: McpConfig): Promise { + try { + await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2)); + } catch (error) { + console.error("Failed to write MCP config:", error); + throw error; + } +} + +// 重新初始化所有客户端 +export async function reinitializeMcpClients() { + logger.info("Reinitializing MCP clients..."); + // 遍历所有客户端,关闭 + try { + for (const [clientId, clientData] of clientsMap.entries()) { + clientData.client?.close(); + } + } catch (error) { + logger.error(`Failed to close clients: ${error}`); + } + // 清空状态 + clientsMap.clear(); + errorClients = []; + initialized = false; + // 重新初始化 + return initializeMcpClients(); +} + // Initialize all configured clients export async function initializeMcpClients() { // If already initialized, return if (initialized) { - return; + return { errorClients }; } logger.info("Starting to initialize MCP clients..."); + errorClients = []; + const config = await getMcpConfig(); // Initialize all clients, key is clientId, value is client config - for (const [clientId, config] of Object.entries(conf.mcpServers)) { + for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) { try { logger.info(`Initializing MCP client: ${clientId}`); - const client = await createClient(config, clientId); + const client = await createClient(serverConfig as ServerConfig, clientId); const primitives = await listPrimitives(client); - clientsMap.set(clientId, { client, primitives }); + clientsMap.set(clientId, { client, primitives, errorMsg: null }); logger.success( `Client [${clientId}] initialized, ${primitives.length} primitives supported`, ); } catch (error) { errorClients.push(clientId); + clientsMap.set(clientId, { + client: null, + primitives: [], + errorMsg: error instanceof Error ? error.message : String(error), + }); logger.error(`Failed to initialize client ${clientId}: ${error}`); } } @@ -58,8 +108,9 @@ export async function initializeMcpClients() { } const availableClients = await getAvailableClients(); - logger.info(`Available clients: ${availableClients.join(",")}`); + + return { errorClients }; } // Execute MCP request @@ -87,9 +138,9 @@ export async function executeMcpAction( // Get all available client IDs export async function getAvailableClients() { - return Array.from(clientsMap.keys()).filter( - (clientId) => !errorClients.includes(clientId), - ); + return Array.from(clientsMap.entries()) + .filter(([_, data]) => data.errorMsg === null) + .map(([clientId]) => clientId); } // Get all primitives from all clients @@ -104,3 +155,62 @@ export async function getAllPrimitives(): Promise< primitives, })); } + +// 获取客户端的 Primitives +export async function getClientPrimitives(clientId: string) { + try { + const clientData = clientsMap.get(clientId); + if (!clientData) { + console.warn(`Client ${clientId} not found in map`); + return null; + } + if (clientData.errorMsg) { + console.warn(`Client ${clientId} has error: ${clientData.errorMsg}`); + return null; + } + return clientData.primitives; + } catch (error) { + console.error(`Failed to get primitives for client ${clientId}:`, error); + return null; + } +} + +// 重启所有客户端 +export async function restartAllClients() { + logger.info("Restarting all MCP clients..."); + + // 清空状态 + clientsMap.clear(); + errorClients = []; + initialized = false; + + // 重新初始化 + await initializeMcpClients(); + + return { + success: errorClients.length === 0, + errorClients, + }; +} + +// 获取所有客户端状态 +export async function getAllClientStatus(): Promise< + Record +> { + const status: Record = {}; + for (const [clientId, data] of clientsMap.entries()) { + status[clientId] = data.errorMsg; + } + return status; +} + +// 检查客户端状态 +export async function getClientErrors(): Promise< + Record +> { + const errors: Record = {}; + for (const [clientId, data] of clientsMap.entries()) { + errors[clientId] = data.errorMsg; + } + return errors; +} diff --git a/app/mcp/mcp_config.json b/app/mcp/mcp_config.json index 3a8b3afaa83..ee092d7f0f2 100644 --- a/app/mcp/mcp_config.json +++ b/app/mcp/mcp_config.json @@ -8,13 +8,29 @@ "/Users/kadxy/Desktop" ] }, - "everything": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-everything"] - }, "docker-mcp": { "command": "uvx", "args": ["docker-mcp"] + }, + "difyworkflow": { + "command": "mcp-difyworkflow-server", + "args": ["-base-url", "23"], + "env": { + "DIFY_WORKFLOW_NAME": "23", + "DIFY_API_KEYS": "23" + } + }, + "postgres": { + "command": "docker", + "args": ["run", "-i", "--rm", "mcp/postgres", null] + }, + "playwright": { + "command": "npx", + "args": ["-y", "@executeautomation/playwright-mcp-server"] + }, + "gdrive": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-gdrive"] } } } diff --git a/app/mcp/preset-server.json b/app/mcp/preset-server.json new file mode 100644 index 00000000000..0daec9aebf1 --- /dev/null +++ b/app/mcp/preset-server.json @@ -0,0 +1,206 @@ +[ + { + "id": "filesystem", + "name": "Filesystem", + "description": "Secure file operations with configurable access controls", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-filesystem"], + "configurable": true, + "configSchema": { + "properties": { + "paths": { + "type": "array", + "description": "Allowed file system paths", + "required": true, + "minItems": 1 + } + } + }, + "argsMapping": { + "paths": { + "type": "spread", + "position": 2 + } + } + }, + { + "id": "github", + "name": "GitHub", + "description": "Repository management, file operations, and GitHub API integration", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-github"], + "configurable": true, + "configSchema": { + "properties": { + "token": { + "type": "string", + "description": "GitHub Personal Access Token", + "required": true + } + } + }, + "argsMapping": { + "token": { + "type": "env", + "key": "GITHUB_PERSONAL_ACCESS_TOKEN" + } + } + }, + { + "id": "gdrive", + "name": "Google Drive", + "description": "File access and search capabilities for Google Drive", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-gdrive"], + "configurable": false + }, + { + "id": "playwright", + "name": "Playwright", + "description": "Browser automation and webscrapping with Playwright", + "command": "npx", + "baseArgs": ["-y", "@executeautomation/playwright-mcp-server"], + "configurable": false + }, + { + "id": "mongodb", + "name": "MongoDB", + "description": "Direct interaction with MongoDB databases", + "command": "node", + "baseArgs": ["dist/index.js"], + "configurable": true, + "configSchema": { + "properties": { + "connectionString": { + "type": "string", + "description": "MongoDB connection string", + "required": true + } + } + }, + "argsMapping": { + "connectionString": { + "type": "single", + "position": 1 + } + } + }, + { + "id": "difyworkflow", + "name": "Dify Workflow", + "description": "Tools to query and execute Dify workflows", + "command": "mcp-difyworkflow-server", + "baseArgs": ["-base-url"], + "configurable": true, + "configSchema": { + "properties": { + "baseUrl": { + "type": "string", + "description": "Dify API base URL", + "required": true + }, + "workflowName": { + "type": "string", + "description": "Dify workflow name", + "required": true + }, + "apiKeys": { + "type": "string", + "description": "Comma-separated Dify API keys", + "required": true + } + } + }, + "argsMapping": { + "baseUrl": { + "type": "single", + "position": 1 + }, + "workflowName": { + "type": "env", + "key": "DIFY_WORKFLOW_NAME" + }, + "apiKeys": { + "type": "env", + "key": "DIFY_API_KEYS" + } + } + }, + { + "id": "postgres", + "name": "PostgreSQL", + "description": "Read-only database access with schema inspection", + "command": "docker", + "baseArgs": ["run", "-i", "--rm", "mcp/postgres"], + "configurable": true, + "configSchema": { + "properties": { + "connectionString": { + "type": "string", + "description": "PostgreSQL connection string", + "required": true + } + } + }, + "argsMapping": { + "connectionString": { + "type": "single", + "position": 4 + } + } + }, + { + "id": "brave-search", + "name": "Brave Search", + "description": "Web and local search using Brave's Search API", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-brave-search"], + "configurable": true, + "configSchema": { + "properties": { + "apiKey": { + "type": "string", + "description": "Brave Search API Key", + "required": true + } + } + }, + "argsMapping": { + "apiKey": { + "type": "env", + "key": "BRAVE_API_KEY" + } + } + }, + { + "id": "google-maps", + "name": "Google Maps", + "description": "Location services, directions, and place details", + "command": "npx", + "baseArgs": ["-y", "@modelcontextprotocol/server-google-maps"], + "configurable": true, + "configSchema": { + "properties": { + "apiKey": { + "type": "string", + "description": "Google Maps API Key", + "required": true + } + } + }, + "argsMapping": { + "apiKey": { + "type": "env", + "key": "GOOGLE_MAPS_API_KEY" + } + } + }, + { + "id": "docker-mcp", + "name": "Docker", + "description": "Run and manage docker containers, docker compose, and logs", + "command": "uvx", + "baseArgs": ["docker-mcp"], + "configurable": false + } +] diff --git a/app/mcp/types.ts b/app/mcp/types.ts index 763121bad88..a97c94e059a 100644 --- a/app/mcp/types.ts +++ b/app/mcp/types.ts @@ -59,3 +59,41 @@ export const McpNotificationsSchema: z.ZodType = z.object({ method: z.string(), params: z.record(z.unknown()).optional(), }); + +// MCP 服务器配置相关类型 +export interface ServerConfig { + command: string; + args: string[]; + env?: Record; +} + +export interface McpConfig { + mcpServers: Record; +} + +export interface ArgsMapping { + type: "spread" | "single" | "env"; + position?: number; + key?: string; +} + +export interface PresetServer { + id: string; + name: string; + description: string; + command: string; + baseArgs: string[]; + configurable: boolean; + configSchema?: { + properties: Record< + string, + { + type: string; + description?: string; + required?: boolean; + minItems?: number; + } + >; + }; + argsMapping?: Record; +} diff --git a/app/mcp/utils.ts b/app/mcp/utils.ts index 5b6dcbf027f..b74509881ef 100644 --- a/app/mcp/utils.ts +++ b/app/mcp/utils.ts @@ -1,10 +1,10 @@ export function isMcpJson(content: string) { - return content.match(/```json:mcp:(\w+)([\s\S]*?)```/); + return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/); } export function extractMcpJson(content: string) { - const match = content.match(/```json:mcp:(\w+)([\s\S]*?)```/); - if (match) { + const match = content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/); + if (match && match.length === 3) { return { clientId: match[1], mcp: JSON.parse(match[2]) }; } return null; diff --git a/next.config.mjs b/next.config.mjs index 80241913929..0e1105d5647 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -32,7 +32,6 @@ const nextConfig = { }, experimental: { forceSwcTransforms: true, - serverActions: true, }, }; diff --git a/yarn.lock b/yarn.lock index 5b9741b2b4c..a99ff08041d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3076,15 +3076,10 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579: - version "1.0.30001617" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" - integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== - -caniuse-lite@^1.0.30001646: - version "1.0.30001649" - resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001649.tgz#3ec700309ca0da2b0d3d5fb03c411b191761c992" - integrity sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ== +caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646: + version "1.0.30001692" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz" + integrity sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A== ccount@^2.0.0: version "2.0.1"