From 8aa9a500fdee762abe5fd8e0bba00065be1725f4 Mon Sep 17 00:00:00 2001 From: Kadxy <2230318258@qq.com> Date: Wed, 15 Jan 2025 16:52:54 +0800 Subject: [PATCH] feat: Optimize MCP configuration logic --- app/components/chat.tsx | 24 ++ app/components/home.tsx | 10 + app/components/mcp-market.module.scss | 194 ++++----- app/components/mcp-market.tsx | 589 ++++++++++++++------------ app/constant.ts | 11 +- app/icons/tool.svg | 5 + app/mcp/actions.ts | 373 ++++++++-------- app/mcp/client.ts | 70 +-- app/mcp/example.ts | 14 +- app/mcp/mcp_config.json | 13 +- app/mcp/preset-server.json | 26 +- app/mcp/types.ts | 60 ++- app/page.tsx | 5 +- app/store/chat.ts | 25 +- 14 files changed, 766 insertions(+), 653 deletions(-) create mode 100644 app/icons/tool.svg diff --git a/app/components/chat.tsx b/app/components/chat.tsx index bbc4444f6..c8d6886e5 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -46,6 +46,7 @@ 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 McpToolIcon from "../icons/tool.svg"; import HeadphoneIcon from "../icons/headphone.svg"; import { BOT_HELLO, @@ -121,6 +122,7 @@ import { isEmpty } from "lodash-es"; import { getModelProvider } from "../utils/model"; import { RealtimeChat } from "@/app/components/realtime-chat"; import clsx from "clsx"; +import { getAvailableClientsCount } from "../mcp/actions"; const localStorage = safeLocalStorage(); @@ -130,6 +132,27 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { loading: () => , }); +const MCPAction = () => { + const navigate = useNavigate(); + const [count, setCount] = useState(0); + + useEffect(() => { + const loadCount = async () => { + const count = await getAvailableClientsCount(); + setCount(count); + }; + loadCount(); + }, []); + + return ( + navigate(Path.McpMarket)} + text={`MCP${count ? ` (${count})` : ""}`} + icon={} + /> + ); +}; + export function SessionConfigModel(props: { onClose: () => void }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); @@ -799,6 +822,7 @@ export function ChatActions(props: { icon={} /> )} + {!isMobileScreen && }
{config.realtimeConfig.enable && ( diff --git a/app/components/home.tsx b/app/components/home.tsx index 32c5b4ac6..8a03c50b6 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -29,6 +29,8 @@ import { getClientConfig } from "../config/client"; import { type ClientApi, getClientApi } from "../client/api"; import { useAccessStore } from "../store"; import clsx from "clsx"; +import { initializeMcpSystem } from "../mcp/actions"; +import { showToast } from "./ui-lib"; export function Loading(props: { noLogo?: boolean }) { return ( @@ -243,6 +245,14 @@ export function Home() { useAccessStore.getState().fetch(); }, []); + useEffect(() => { + // 初始化 MCP 系统 + initializeMcpSystem().catch((error) => { + console.error("Failed to initialize MCP system:", error); + showToast("Failed to initialize MCP system"); + }); + }, []); + if (!useHasHydrated()) { return ; } diff --git a/app/components/mcp-market.module.scss b/app/components/mcp-market.module.scss index 5e4b6e9b0..93c6b67de 100644 --- a/app/components/mcp-market.module.scss +++ b/app/components/mcp-market.module.scss @@ -39,8 +39,6 @@ } .mcp-market-item { - display: flex; - justify-content: space-between; padding: 20px; border: var(--border-in-light); animation: slide-in ease 0.3s; @@ -68,118 +66,106 @@ .mcp-market-header { display: flex; - align-items: center; + justify-content: space-between; + align-items: flex-start; + width: 100%; .mcp-market-title { - .mcp-market-name { - font-size: 14px; - font-weight: bold; - display: flex; + 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; - gap: 8px; + margin-left: 10px; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background-color: #22c55e; + color: #fff; - .server-status { + &.error { + background-color: #ef4444; + } + + .error-message { + margin-left: 4px; 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; + .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 { - transform: translateY(-1px); - filter: brightness(1.1); + opacity: 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; + svg { + width: 14px; + height: 14px; } } - } - @media screen and (max-width: 600px) { - flex-direction: column; - gap: 10px; + .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: 8px; + align-items: flex-start; + flex-shrink: 0; + min-width: 180px; justify-content: flex-end; + + :global(.icon-button) { + transition: all 0.3s ease; + border: 1px solid transparent; + + &:hover { + transform: translateY(-1px); + filter: brightness(1.1); + } + } } } } @@ -312,11 +298,6 @@ outline: none; box-shadow: 0 0 0 2px var(--primary-10); } - - &::placeholder { - color: var(--gray-300) !important; - opacity: 1; - } } .browse-button { @@ -534,7 +515,7 @@ } } - .primitives-list { + .tools-list { display: flex; flex-direction: column; gap: 16px; @@ -545,11 +526,11 @@ word-break: break-word; box-sizing: border-box; - .primitive-item { + .tool-item { width: 100%; box-sizing: border-box; - .primitive-name { + .tool-name { font-size: 14px; font-weight: 600; color: var(--black); @@ -560,7 +541,7 @@ width: 100%; } - .primitive-description { + .tool-description { font-size: 13px; color: var(--gray-500); line-height: 1.6; @@ -590,9 +571,12 @@ border-radius: 10px; padding: 10px; margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 10px; .list-header { - margin-bottom: 10px; + margin-bottom: 0; .list-title { font-size: 14px; diff --git a/app/components/mcp-market.tsx b/app/components/mcp-market.tsx index 926e64b29..d93754549 100644 --- a/app/components/mcp-market.tsx +++ b/app/components/mcp-market.tsx @@ -7,22 +7,29 @@ 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 { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import presetServersJson from "../mcp/preset-server.json"; -const presetServers = presetServersJson as PresetServer[]; import { - getMcpConfig, - updateMcpConfig, - getClientPrimitives, + addMcpServer, + getClientStatus, + getClientTools, + getMcpConfigFromFile, + removeMcpServer, restartAllClients, - getClientErrors, - refreshClientStatus, } from "../mcp/actions"; -import { McpConfig, PresetServer, ServerConfig } from "../mcp/types"; +import { + ListToolsResponse, + McpConfigData, + PresetServer, + ServerConfig, +} from "../mcp/types"; import clsx from "clsx"; +const presetServers = presetServersJson as PresetServer[]; + interface ConfigProperty { type: string; description?: string; @@ -33,67 +40,71 @@ interface ConfigProperty { 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 [editingServerId, setEditingServerId] = useState(); + const [tools, setTools] = useState(null); + const [viewingServerId, setViewingServerId] = useState(); const [isLoading, setIsLoading] = useState(false); - const [clientErrors, setClientErrors] = useState< - Record + const [config, setConfig] = useState(); + const [clientStatuses, setClientStatuses] = useState< + Record< + string, + { + status: "active" | "error" | "undefined"; + errorMsg: string | null; + } + > >({}); - // 更新服务器状态 - const updateServerStatus = async () => { - await refreshClientStatus(); - const errors = await getClientErrors(); - setClientErrors(errors); + // 检查服务器是否已添加 + const isServerAdded = (id: string) => { + return id in (config?.mcpServers ?? {}); }; - // 初始加载配置 + // 获取客户端状态 + const updateClientStatus = async (clientId: string) => { + const status = await getClientStatus(clientId); + setClientStatuses((prev) => ({ + ...prev, + [clientId]: status, + })); + return status; + }; + + // 从服务器获取初始状态 useEffect(() => { - const init = async () => { + const loadInitialState = async () => { try { setIsLoading(true); - const data = await getMcpConfig(); - setConfig(data); - await updateServerStatus(); + const config = await getMcpConfigFromFile(); + setConfig(config); + + // 获取所有客户端的状态 + const statuses: Record = {}; + for (const clientId of Object.keys(config.mcpServers)) { + const status = await getClientStatus(clientId); + statuses[clientId] = status; + } + setClientStatuses(statuses); } catch (error) { - showToast("Failed to load configuration"); - console.error(error); + console.error("Failed to load initial state:", error); + showToast("Failed to load initial state"); } finally { setIsLoading(false); } }; - init().then(); + loadInitialState(); }, []); - // 保存配置 - const saveConfig = async (newConfig: McpConfig) => { - try { - setIsLoading(true); - await updateMcpConfig(newConfig); - setConfig(newConfig); - // 配置改变时需要重新初始化 - await restartAllClients(); - 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; - }; + // Debug: 监控状态变化 + useEffect(() => { + console.log("MCP Market - Current config:", config); + console.log("MCP Market - Current clientStatuses:", clientStatuses); + }, [config, clientStatuses]); // 加载当前编辑服务器的配置 useEffect(() => { - if (editingServerId) { + if (editingServerId && config) { const currentConfig = config.mcpServers[editingServerId]; if (currentConfig) { // 从当前配置中提取用户配置 @@ -123,7 +134,7 @@ export function McpMarketPage() { setUserConfig({}); } } - }, [editingServerId, config.mcpServers]); + }, [editingServerId, config]); // 保存服务器配置 const saveServerConfig = async () => { @@ -131,6 +142,7 @@ export function McpMarketPage() { if (!preset || !preset.configSchema || !editingServerId) return; try { + setIsLoading(true); // 构建服务器配置 const args = [...preset.baseArgs]; const env: Record = {}; @@ -160,22 +172,113 @@ export function McpMarketPage() { ...(Object.keys(env).length > 0 ? { env } : {}), }; - // 更新配置 - const newConfig = { - ...config, - mcpServers: { - ...config.mcpServers, - [editingServerId]: serverConfig, - }, - }; + // 更新配置并初始化新服务器 + const newConfig = await addMcpServer(editingServerId, serverConfig); + setConfig(newConfig); + + // 更新状态 + const status = await getClientStatus(editingServerId); + setClientStatuses((prev) => ({ + ...prev, + [editingServerId]: status, + })); - await saveConfig(newConfig); setEditingServerId(undefined); showToast("Server configuration saved successfully"); } catch (error) { showToast( error instanceof Error ? error.message : "Failed to save configuration", ); + } finally { + setIsLoading(false); + } + }; + + // 获取服务器支持的 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 handleRestartAll = async () => { + try { + setIsLoading(true); + const newConfig = await restartAllClients(); + setConfig(newConfig); + + // 更新所有客户端状态 + const statuses: Record = {}; + for (const clientId of Object.keys(newConfig.mcpServers)) { + const status = await getClientStatus(clientId); + statuses[clientId] = status; + } + setClientStatuses(statuses); + + showToast("Successfully restarted all clients"); + } 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 = await addMcpServer(preset.id, serverConfig); + setConfig(newConfig); + + // 更新状态 + const status = await getClientStatus(preset.id); + setClientStatuses((prev) => ({ + ...prev, + [preset.id]: status, + })); + } finally { + setIsLoading(false); + } + } else { + // 如果需要配置,打开配置对话框 + setEditingServerId(preset.id); + setUserConfig({}); + } + }; + + // 移除服务器 + const removeServer = async (id: string) => { + try { + setIsLoading(true); + const newConfig = await removeMcpServer(id); + setConfig(newConfig); + + // 移除状态 + setClientStatuses((prev) => { + const newStatuses = { ...prev }; + delete newStatuses[id]; + return newStatuses; + }); + } finally { + setIsLoading(false); } }; @@ -188,8 +291,17 @@ export function McpMarketPage() { ([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 ( - +
{(currentValue as string[]).map( (value: string, index: number) => ( @@ -197,7 +309,7 @@ export function McpMarketPage() { { const newValue = [...currentValue] as string[]; newValue[index] = e.target.value; @@ -218,7 +330,7 @@ export function McpMarketPage() { )} } - text="Add Path" + text={addButtonText} className={styles["add-button"]} bordered onClick={() => { @@ -251,83 +363,146 @@ export function McpMarketPage() { ); }; - // 获取服务器的 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 checkServerStatus = (clientId: string) => { + return clientStatuses[clientId] || { status: "undefined", errorMsg: null }; }; - // 重启所有客户端 - 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 renderServerList = () => { + 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 addServer = async (preset: PresetServer) => { - if (!preset.configurable) { - try { - setIsLoading(true); - showToast("Creating MCP client..."); - // 如果服务器不需要配置,直接添加 - const serverConfig: ServerConfig = { - command: preset.command, - args: [...preset.baseArgs], + // 定义状态优先级 + const statusPriority = { + error: 0, + active: 1, + undefined: 2, }; - 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); - } + // 首先按状态排序 + if (aStatus !== bStatus) { + return statusPriority[aStatus] - statusPriority[bStatus]; + } + + // 然后按名称排序 + return a.name.localeCompare(b.name); + }) + .map((server) => ( +
+
+
+
+ {server.name} + {checkServerStatus(server.id).status !== "undefined" && ( + + {checkServerStatus(server.id).status === "error" ? ( + <> + Error + + : {checkServerStatus(server.id).errorMsg} + + + ) : ( + "Active" + )} + + )} + {server.repo && ( + + + + )} +
+
+ {server.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ {server.description} +
+
+
+ {isServerAdded(server.id) ? ( + <> + {server.configurable && ( + } + text="Configure" + className={clsx({ + [styles["action-error"]]: + checkServerStatus(server.id).status === "error", + })} + onClick={() => setEditingServerId(server.id)} + disabled={isLoading} + /> + )} + } + text="Tools" + onClick={async () => { + setViewingServerId(server.id); + await loadTools(server.id); + }} + disabled={ + isLoading || + checkServerStatus(server.id).status === "error" + } + /> + } + text="Remove" + className={styles["action-danger"]} + onClick={() => removeServer(server.id)} + disabled={isLoading} + /> + + ) : ( + } + text="Add" + className={styles["action-primary"]} + onClick={() => addServer(server)} + disabled={isLoading} + /> + )} +
+
+
+ )); }; return ( @@ -342,7 +517,7 @@ export function McpMarketPage() { )}
- {Object.keys(config.mcpServers).length} servers configured + {Object.keys(config?.mcpServers ?? {}).length} servers configured
@@ -351,7 +526,7 @@ export function McpMarketPage() { } bordered - onClick={handleRestart} + onClick={handleRestartAll} text="Restart All" disabled={isLoading} /> @@ -378,121 +553,10 @@ export function McpMarketPage() { /> -
- {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="Tools" - 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} - /> - )} -
-
- ))} -
+
{renderServerList()}
+ {/*编辑服务器配置*/} {editingServerId && (
)} + {/*支持的Tools*/} {viewingServerId && (
, ]} > -
+
{isLoading ? (
Loading...
- ) : primitives.filter((p) => p.type === "tool").length > 0 ? ( - primitives - .filter((p) => p.type === "tool") - .map((primitive, index) => ( -
-
- {primitive.value.name} + ) : tools?.tools ? ( + tools.tools.map( + (tool: ListToolsResponse["tools"], index: number) => ( +
+
{tool.name}
+
+ {tool.description}
- {primitive.value.description && ( -
- {primitive.value.description} -
- )}
- )) + ), + ) ) : (
No tools available
)} diff --git a/app/constant.ts b/app/constant.ts index 3c0ff6213..9cdf197bf 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -88,6 +88,7 @@ export enum StoreKey { Update = "chat-update", Sync = "sync", SdList = "sd-list", + Mcp = "mcp-store", } export const DEFAULT_SIDEBAR_WIDTH = 300; @@ -254,18 +255,18 @@ Latex inline: \\(x^2\\) Latex block: $$e=mc^2$$ `; -export const MCP_PRIMITIVES_TEMPLATE = ` +export const MCP_TOOLS_TEMPLATE = ` [clientId] {{ clientId }} -[primitives] -{{ primitives }} +[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. TOOLS AVAILABLE: -{{ MCP_PRIMITIVES }} +1. AVAILABLE TOOLS: +{{ MCP_TOOLS }} 2. WHEN TO USE TOOLS: - ALWAYS USE TOOLS when they can help answer user questions diff --git a/app/icons/tool.svg b/app/icons/tool.svg new file mode 100644 index 000000000..f7543e201 --- /dev/null +++ b/app/icons/tool.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/mcp/actions.ts b/app/mcp/actions.ts index bf38dcc63..6b5ea6df3 100644 --- a/app/mcp/actions.ts +++ b/app/mcp/actions.ts @@ -1,236 +1,217 @@ "use server"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { createClient, executeRequest, - listPrimitives, - Primitive, + listTools, + removeClient, } from "./client"; import { MCPClientLogger } from "./logger"; -import { McpRequestMessage, McpConfig, ServerConfig } from "./types"; +import { + DEFAULT_MCP_CONFIG, + McpClientData, + McpConfigData, + McpRequestMessage, + 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 | null; primitives: Primitive[]; errorMsg: string | null } ->(); - -// Whether initialized -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: {} }; - } +const clientsMap = new Map(); + +// 获取客户端状态 +export async function getClientStatus(clientId: string) { + const status = clientsMap.get(clientId); + if (!status) return { status: "undefined" as const, errorMsg: null }; + + return { + status: status.errorMsg ? ("error" as const) : ("active" as const), + errorMsg: status.errorMsg, + }; } -// 更新 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 getClientTools(clientId: string) { + return clientsMap.get(clientId)?.tools ?? null; } -// 重新初始化所有客户端 -export async function reinitializeMcpClients() { - logger.info("Reinitializing MCP clients..."); - // 遍历所有客户端,关闭 - try { - for (const [clientId, clientData] of clientsMap.entries()) { - clientData.client?.close(); +// 获取可用客户端数量 +export async function getAvailableClientsCount() { + let count = 0; + clientsMap.forEach((map) => { + if (!map.errorMsg) { + count += map?.tools?.tools?.length ?? 0; } - } catch (error) { - logger.error(`Failed to close clients: ${error}`); - } - // 清空状态 - clientsMap.clear(); - errorClients = []; - initialized = false; - // 重新初始化 - return initializeMcpClients(); + }); + return count; } -// Initialize all configured clients -export async function initializeMcpClients() { - // If already initialized, return - if (initialized) { - return { errorClients }; +// 获取所有客户端工具 +export async function getAllTools() { + const result = []; + for (const [clientId, status] of clientsMap.entries()) { + result.push({ + clientId, + tools: status.tools, + }); } - - 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, serverConfig] of Object.entries(config.mcpServers)) { - try { - logger.info(`Initializing MCP client: ${clientId}`); - const client = await createClient(serverConfig as ServerConfig, clientId); - const primitives = await listPrimitives(client); - 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}`); - } - } - - initialized = true; - - if (errorClients.length > 0) { - logger.warn(`Failed to initialize clients: ${errorClients.join(", ")}`); - } else { - logger.success("All MCP clients initialized"); - } - - const availableClients = await getAvailableClients(); - logger.info(`Available clients: ${availableClients.join(",")}`); - - return { errorClients }; + return result; } -// Execute MCP request -export async function executeMcpAction( +// 初始化单个客户端 +async function initializeSingleClient( clientId: string, - request: McpRequestMessage, + serverConfig: ServerConfig, ) { + logger.info(`Initializing client [${clientId}]...`); try { - // Find the corresponding client - const client = clientsMap.get(clientId)?.client; - if (!client) { - logger.error(`Client ${clientId} not found`); - return; - } - - logger.info(`Executing MCP request for ${clientId}`); - - // Execute request and return result - return await executeRequest(client, request); + const client = await createClient(clientId, serverConfig); + const tools = await listTools(client); + clientsMap.set(clientId, { client, tools, errorMsg: null }); + logger.success(`Client [${clientId}] initialized successfully`); } catch (error) { - logger.error(`MCP execution error: ${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 { + 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; } } -// Get all available client IDs -export async function getAvailableClients() { - return Array.from(clientsMap.entries()) - .filter(([_, data]) => data.errorMsg === null) - .map(([clientId]) => clientId); -} - -// Get all primitives from all clients -export async function getAllPrimitives(): Promise< - { - clientId: string; - primitives: Primitive[]; - }[] -> { - return Array.from(clientsMap.entries()).map(([clientId, { primitives }]) => ({ - clientId, - primitives, - })); -} - -// 获取客户端的 Primitives -export async function getClientPrimitives(clientId: string) { +// 添加服务器 +export async function addMcpServer(clientId: string, config: ServerConfig) { 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; + const currentConfig = await getMcpConfigFromFile(); + const newConfig = { + ...currentConfig, + mcpServers: { + ...currentConfig.mcpServers, + [clientId]: config, + }, + }; + await updateMcpConfig(newConfig); + // 只初始化新添加的服务器 + await initializeSingleClient(clientId, config); + return newConfig; } catch (error) { - console.error(`Failed to get primitives for client ${clientId}:`, error); - return null; + logger.error(`Failed to add 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 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; -} - -// 获取客户端状态,不重新初始化 -export async function refreshClientStatus() { - logger.info("Refreshing client status..."); - - // 如果还没初始化过,则初始化 - if (!initialized) { - return initializeMcpClients(); - } - - // 否则只更新错误状态 - errorClients = []; - for (const [clientId, clientData] of clientsMap.entries()) { - if (clientData.errorMsg !== null) { - errorClients.push(clientId); + logger.info("Restarting all clients..."); + try { + // 关闭所有客户端 + for (const client of clientsMap.values()) { + if (client.client) { + await removeClient(client.client); + } } - } + // 清空状态 + clientsMap.clear(); - return { errorClients }; + // 重新初始化 + 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 { + 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 { + try { + await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2)); + } catch (error) { + throw error; + } +} + +// 重新初始化单个客户端 +export async function reinitializeClient(clientId: string) { + const config = await getMcpConfigFromFile(); + const serverConfig = config.mcpServers[clientId]; + if (!serverConfig) { + throw new Error(`Server config not found for client ${clientId}`); + } + await initializeSingleClient(clientId, serverConfig); } diff --git a/app/mcp/client.ts b/app/mcp/client.ts index 6650f9e2b..b7b511a92 100644 --- a/app/mcp/client.ts +++ b/app/mcp/client.ts @@ -1,85 +1,45 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { MCPClientLogger } from "./logger"; -import { McpRequestMessage } from "./types"; +import { ListToolsResponse, McpRequestMessage, ServerConfig } from "./types"; import { z } from "zod"; -export interface ServerConfig { - command: string; - args?: string[]; - env?: Record; -} - const logger = new MCPClientLogger(); export async function createClient( - serverConfig: ServerConfig, - name: string, + id: string, + config: ServerConfig, ): Promise { - logger.info(`Creating client for server ${name}`); + logger.info(`Creating client for ${id}...`); const transport = new StdioClientTransport({ - command: serverConfig.command, - args: serverConfig.args, - env: serverConfig.env, + command: config.command, + args: config.args, + env: config.env, }); + const client = new Client( { - name: `nextchat-mcp-client-${name}`, + name: `nextchat-mcp-client-${id}`, version: "1.0.0", }, { - capabilities: { - // roots: { - // listChanged: true, - // }, - }, + capabilities: {}, }, ); await client.connect(transport); return client; } -export interface Primitive { - type: "resource" | "tool" | "prompt"; - value: any; +export async function removeClient(client: Client) { + logger.info(`Removing client...`); + await client.close(); } -/** List all resources, tools, and prompts */ -export async function listPrimitives(client: Client): Promise { - const capabilities = client.getServerCapabilities(); - const primitives: Primitive[] = []; - const promises = []; - if (capabilities?.resources) { - promises.push( - client.listResources().then(({ resources }) => { - resources.forEach((item) => - primitives.push({ type: "resource", value: item }), - ); - }), - ); - } - if (capabilities?.tools) { - promises.push( - client.listTools().then(({ tools }) => { - tools.forEach((item) => primitives.push({ type: "tool", value: item })); - }), - ); - } - if (capabilities?.prompts) { - promises.push( - client.listPrompts().then(({ prompts }) => { - prompts.forEach((item) => - primitives.push({ type: "prompt", value: item }), - ); - }), - ); - } - await Promise.all(promises); - return primitives; +export async function listTools(client: Client): Promise { + return client.listTools(); } -/** Execute a request */ export async function executeRequest( client: Client, request: McpRequestMessage, diff --git a/app/mcp/example.ts b/app/mcp/example.ts index f3b91fb8c..986196d63 100644 --- a/app/mcp/example.ts +++ b/app/mcp/example.ts @@ -1,27 +1,23 @@ -import { createClient, listPrimitives } from "@/app/mcp/client"; +import { createClient, listTools } from "@/app/mcp/client"; import { MCPClientLogger } from "@/app/mcp/logger"; import conf from "./mcp_config.json"; const logger = new MCPClientLogger("MCP Server Example", true); -const TEST_SERVER = "everything"; +const TEST_SERVER = "filesystem"; async function main() { logger.info(`All MCP servers: ${Object.keys(conf.mcpServers).join(", ")}`); logger.info(`Connecting to server ${TEST_SERVER}...`); - const client = await createClient(conf.mcpServers[TEST_SERVER], TEST_SERVER); - const primitives = await listPrimitives(client); + const client = await createClient(TEST_SERVER, conf.mcpServers[TEST_SERVER]); + const tools = await listTools(client); logger.success(`Connected to server ${TEST_SERVER}`); logger.info( - `${TEST_SERVER} supported primitives:\n${JSON.stringify( - primitives.filter((i) => i.type === "tool"), - null, - 2, - )}`, + `${TEST_SERVER} supported primitives:\n${JSON.stringify(tools, null, 2)}`, ); } diff --git a/app/mcp/mcp_config.json b/app/mcp/mcp_config.json index da39e4ffa..8a235acc9 100644 --- a/app/mcp/mcp_config.json +++ b/app/mcp/mcp_config.json @@ -1,3 +1,12 @@ { - "mcpServers": {} -} + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "." + ] + } + } +} \ No newline at end of file diff --git a/app/mcp/preset-server.json b/app/mcp/preset-server.json index 0daec9aeb..b44b841d2 100644 --- a/app/mcp/preset-server.json +++ b/app/mcp/preset-server.json @@ -2,7 +2,9 @@ { "id": "filesystem", "name": "Filesystem", - "description": "Secure file operations with configurable access controls", + "description": "Secure file operations with configurable access controlsSecure file operations with configurable access controlsSecure file operations with configurable access controlsSecure file operations with configurable access controls", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "tags": ["filesystem", "storage", "local"], "command": "npx", "baseArgs": ["-y", "@modelcontextprotocol/server-filesystem"], "configurable": true, @@ -12,7 +14,9 @@ "type": "array", "description": "Allowed file system paths", "required": true, - "minItems": 1 + "minItems": 1, + "itemLabel": "Path", + "addButtonText": "Add Path" } } }, @@ -27,6 +31,8 @@ "id": "github", "name": "GitHub", "description": "Repository management, file operations, and GitHub API integration", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/github", + "tags": ["github", "git", "api", "vcs"], "command": "npx", "baseArgs": ["-y", "@modelcontextprotocol/server-github"], "configurable": true, @@ -50,6 +56,8 @@ "id": "gdrive", "name": "Google Drive", "description": "File access and search capabilities for Google Drive", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/gdrive", + "tags": ["google", "drive", "storage", "cloud"], "command": "npx", "baseArgs": ["-y", "@modelcontextprotocol/server-gdrive"], "configurable": false @@ -58,6 +66,8 @@ "id": "playwright", "name": "Playwright", "description": "Browser automation and webscrapping with Playwright", + "repo": "https://github.com/executeautomation/mcp-playwright", + "tags": ["browser", "automation", "scraping"], "command": "npx", "baseArgs": ["-y", "@executeautomation/playwright-mcp-server"], "configurable": false @@ -66,6 +76,8 @@ "id": "mongodb", "name": "MongoDB", "description": "Direct interaction with MongoDB databases", + "repo": "", + "tags": ["database", "mongodb", "nosql"], "command": "node", "baseArgs": ["dist/index.js"], "configurable": true, @@ -89,6 +101,8 @@ "id": "difyworkflow", "name": "Dify Workflow", "description": "Tools to query and execute Dify workflows", + "repo": "https://github.com/gotoolkits/mcp-difyworkflow-server", + "tags": ["workflow", "automation", "dify"], "command": "mcp-difyworkflow-server", "baseArgs": ["-base-url"], "configurable": true, @@ -130,6 +144,8 @@ "id": "postgres", "name": "PostgreSQL", "description": "Read-only database access with schema inspection", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres", + "tags": ["database", "postgresql", "sql"], "command": "docker", "baseArgs": ["run", "-i", "--rm", "mcp/postgres"], "configurable": true, @@ -153,6 +169,8 @@ "id": "brave-search", "name": "Brave Search", "description": "Web and local search using Brave's Search API", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search", + "tags": ["search", "brave", "api"], "command": "npx", "baseArgs": ["-y", "@modelcontextprotocol/server-brave-search"], "configurable": true, @@ -176,6 +194,8 @@ "id": "google-maps", "name": "Google Maps", "description": "Location services, directions, and place details", + "repo": "https://github.com/modelcontextprotocol/servers/tree/main/src/google-maps", + "tags": ["maps", "google", "location", "api"], "command": "npx", "baseArgs": ["-y", "@modelcontextprotocol/server-google-maps"], "configurable": true, @@ -199,6 +219,8 @@ "id": "docker-mcp", "name": "Docker", "description": "Run and manage docker containers, docker compose, and logs", + "repo": "https://github.com/QuantGeekDev/docker-mcp", + "tags": ["docker", "container", "devops"], "command": "uvx", "baseArgs": ["docker-mcp"], "configurable": false diff --git a/app/mcp/types.ts b/app/mcp/types.ts index a97c94e05..da6731d28 100644 --- a/app/mcp/types.ts +++ b/app/mcp/types.ts @@ -1,6 +1,7 @@ // 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"; @@ -60,6 +61,32 @@ export const McpNotificationsSchema: z.ZodType = z.object({ 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; + +interface McpActiveClient { + client: Client; + tools: ListToolsResponse; + errorMsg: null; +} + +interface McpErrorClient { + client: null; + tools: null; + errorMsg: string; +} + // MCP 服务器配置相关类型 export interface ServerConfig { command: string; @@ -67,23 +94,52 @@ export interface ServerConfig { env?: Record; } -export interface McpConfig { +export interface McpConfigData { + // MCP Server 的配置 mcpServers: Record; } +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, @@ -95,5 +151,7 @@ export interface PresetServer { } >; }; + + // MCP Server 的参数映射 argsMapping?: Record; } diff --git a/app/page.tsx b/app/page.tsx index d4ba2a276..48a702201 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,13 @@ import { Analytics } from "@vercel/analytics/react"; import { Home } from "./components/home"; import { getServerSideConfig } from "./config/server"; -import { initializeMcpClients } from "./mcp/actions"; +import { initializeMcpSystem } from "./mcp/actions"; const serverConfig = getServerSideConfig(); export default async function App() { - await initializeMcpClients(); + // 初始化 MCP 系统 + await initializeMcpSystem(); return ( <> diff --git a/app/store/chat.ts b/app/store/chat.ts index 4a70c9296..6c6c70a1c 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -21,8 +21,8 @@ import { DEFAULT_SYSTEM_TEMPLATE, GEMINI_SUMMARIZE_MODEL, KnowledgeCutOffDate, - MCP_PRIMITIVES_TEMPLATE, MCP_SYSTEM_TEMPLATE, + MCP_TOOLS_TEMPLATE, ServiceProvider, StoreKey, SUMMARIZE_MODEL, @@ -35,7 +35,7 @@ import { ModelConfig, ModelType, useAppConfig } from "./config"; import { useAccessStore } from "./access"; import { collectModelsWithDefaultModel } from "../utils/model"; import { createEmptyMask, Mask } from "./mask"; -import { executeMcpAction, getAllPrimitives } from "../mcp/actions"; +import { executeMcpAction, getAllTools } from "../mcp/actions"; import { extractMcpJson, isMcpJson } from "../mcp/utils"; const localStorage = safeLocalStorage(); @@ -199,23 +199,24 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { } async function getMcpSystemPrompt(): Promise { - let primitives = await getAllPrimitives(); - primitives = primitives.filter((i) => - i.primitives.some((p) => p.type === "tool"), - ); + const tools = await getAllTools(); - let primitivesString = ""; - primitives.forEach((i) => { - primitivesString += MCP_PRIMITIVES_TEMPLATE.replace( + let toolsStr = ""; + + tools.forEach((i) => { + // error client has no tools + if (!i.tools) return; + + toolsStr += MCP_TOOLS_TEMPLATE.replace( "{{ clientId }}", i.clientId, ).replace( - "{{ primitives }}", - i.primitives.map((p) => JSON.stringify(p, null, 2)).join("\n"), + "{{ tools }}", + i.tools.tools.map((p: object) => JSON.stringify(p, null, 2)).join("\n"), ); }); - return MCP_SYSTEM_TEMPLATE.replace("{{ MCP_PRIMITIVES }}", primitivesString); + return MCP_SYSTEM_TEMPLATE.replace("{{ MCP_TOOLS }}", toolsStr); } const DEFAULT_CHAT_STATE = {