From abb1d071b1882ab749255e3bb96dce5e6e6733c0 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Mon, 30 Mar 2026 23:32:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20MCP=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=BA=E4=BD=BF=E7=94=A8=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/mcp_config.json.template | 13 --- src/cli/maisaka_cli.py | 12 ++- src/config/config.py | 15 +++- src/config/official_configs.py | 136 ++++++++++++++++++++++++++++--- src/maisaka/chat_loop_service.py | 6 +- src/maisaka/runtime.py | 6 +- src/mcp_module/__init__.py | 3 +- src/mcp_module/config.py | 113 +++++++++---------------- src/mcp_module/connection.py | 15 ++-- src/mcp_module/manager.py | 30 ++++--- 10 files changed, 214 insertions(+), 135 deletions(-) delete mode 100644 config/mcp_config.json.template diff --git a/config/mcp_config.json.template b/config/mcp_config.json.template deleted file mode 100644 index 89207601..00000000 --- a/config/mcp_config.json.template +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mcpServers": { - "tavily": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://mcp.tavily.com/mcp/?tavilyApiKey=YOUR_API_KEY_HERE" - ], - "env": {} - } - } -} diff --git a/src/cli/maisaka_cli.py b/src/cli/maisaka_cli.py index 9b2b3c63..f3c88d73 100644 --- a/src/cli/maisaka_cli.py +++ b/src/cli/maisaka_cli.py @@ -3,7 +3,6 @@ MaiSaka CLI and conversation loop. """ from datetime import datetime -from pathlib import Path from typing import Optional import asyncio @@ -259,7 +258,7 @@ class BufferCLI: knowledge_result = results[0] if results else None if isinstance(knowledge_result, Exception): console.print(f"[warning]知识分析失败:{knowledge_result}[/warning]") - elif knowledge_result: + elif isinstance(knowledge_result, str) and knowledge_result.strip(): knowledge_analysis = knowledge_result if global_config.maisaka.show_thinking: console.print( @@ -333,7 +332,7 @@ class BufferCLI: should_stop = True elif tool_call.func_name == "reply": - reply = await self._generate_visible_reply(chat_history, response.content) + reply = await self._generate_visible_reply(chat_history, response.content or "") chat_history.append( ToolResultMessage( content="已生成并记录可见回复。", @@ -384,8 +383,7 @@ class BufferCLI: async def _init_mcp(self) -> None: """初始化 MCP 服务并注册暴露的工具。""" - config_path = Path(__file__).resolve().parents[2] / "config" / "mcp_config.json" - self._mcp_manager = await MCPManager.from_config(str(config_path)) + self._mcp_manager = await MCPManager.from_app_config(global_config.mcp) if self._mcp_manager and self._chat_loop_service: mcp_tools = self._mcp_manager.get_openai_tools() @@ -429,10 +427,10 @@ class BufferCLI: async def run(self) -> None: """主交互循环。""" - if global_config.maisaka.enable_mcp: + if global_config.mcp.enable: await self._init_mcp() else: - console.print("[muted]MCP 已禁用(ENABLE_MCP=false)[/muted]") + console.print("[muted]MCP 已禁用(mcp.enable=false)[/muted]") self._reader.start(asyncio.get_event_loop()) self._show_banner() diff --git a/src/config/config.py b/src/config/config.py index 44730ab9..318c987f 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,6 +1,6 @@ from datetime import datetime from pathlib import Path -from typing import Any, Callable, Mapping, Sequence, TypeVar +from typing import Any, Callable, Mapping, Sequence, TypeVar, cast import asyncio import copy @@ -27,6 +27,7 @@ from .official_configs import ( LPMMKnowledgeConfig, MaiSakaConfig, MaimMessageConfig, + MCPConfig, PluginRuntimeConfig, MemoryConfig, MessageReceiveConfig, @@ -56,7 +57,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config" BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute() MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() MMC_VERSION: str = "1.0.0" -CONFIG_VERSION: str = "8.1.11" +CONFIG_VERSION: str = "8.2.0" MODEL_CONFIG_VERSION: str = "1.13.1" logger = get_logger("config") @@ -134,6 +135,9 @@ class Config(ConfigBase): maisaka: MaiSakaConfig = Field(default_factory=MaiSakaConfig) """MaiSaka对话系统配置类""" + mcp: MCPConfig = Field(default_factory=MCPConfig) + """MCP 配置类""" + plugin_runtime: PluginRuntimeConfig = Field(default_factory=PluginRuntimeConfig) """插件运行时配置类""" @@ -332,7 +336,12 @@ class ConfigManager: changed_scopes: 本次热重载命中的配置范围。 """ - result = callback(changed_scopes) if self._callback_accepts_scopes(callback) else callback() + if self._callback_accepts_scopes(callback): + callback_with_scopes = cast(Callable[[Sequence[str]], object], callback) + result = callback_with_scopes(changed_scopes) + else: + callback_without_scopes = cast(Callable[[], object], callback) + result = callback_without_scopes() if asyncio.iscoroutine(result): await result diff --git a/src/config/official_configs.py b/src/config/official_configs.py index e4e96bf5..edb3e2c2 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -1,6 +1,7 @@ -from .config_base import ConfigBase, Field import re -from typing import Optional, Literal +from typing import Literal, Optional + +from .config_base import ConfigBase, Field """ 须知: @@ -1493,15 +1494,6 @@ class MaiSakaConfig(ConfigBase): ) """启用知识库模块""" - enable_mcp: bool = Field( - default=True, - json_schema_extra={ - "x-widget": "switch", - "x-icon": "zap", - }, - ) - """启用 MCP (Model Context Protocol) 支持""" - show_analyze_cognition_prompt: bool = Field( default=False, json_schema_extra={ @@ -1577,6 +1569,128 @@ class MaiSakaConfig(ConfigBase): """Maisaka终端图片预览的字符宽度""" +class MCPServerItemConfig(ConfigBase): + """单个 MCP 服务器配置。""" + + name: str = Field( + default="", + json_schema_extra={ + "x-widget": "input", + "x-icon": "tag", + }, + ) + """服务器名称,必须唯一""" + + enabled: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "power", + }, + ) + """是否启用当前 MCP 服务器""" + + transport: Literal["stdio", "sse"] = Field( + default="stdio", + json_schema_extra={ + "x-widget": "select", + "x-icon": "shuffle", + }, + ) + """传输方式,可选 stdio 或 sse""" + + command: str = Field( + default="", + json_schema_extra={ + "x-widget": "input", + "x-icon": "terminal", + }, + ) + """stdio 模式下启动服务器的命令""" + + args: list[str] = Field( + default_factory=lambda: [], + json_schema_extra={ + "x-widget": "custom", + "x-icon": "list", + }, + ) + """stdio 模式下的命令参数列表""" + + env: dict[str, str] = Field( + default_factory=lambda: {}, + json_schema_extra={ + "x-widget": "custom", + "x-icon": "variable", + }, + ) + """stdio 模式下附加的环境变量""" + + url: str = Field( + default="", + json_schema_extra={ + "x-widget": "input", + "x-icon": "link", + }, + ) + """sse 模式下的服务地址""" + + headers: dict[str, str] = Field( + default_factory=lambda: {}, + json_schema_extra={ + "x-widget": "custom", + "x-icon": "file-json", + }, + ) + """sse 模式下附加的请求头""" + + def model_post_init(self, context: Optional[dict] = None) -> None: + """验证 MCP 服务器配置。""" + + if not self.name.strip(): + raise ValueError("MCPServerItemConfig.name 不能为空") + + if self.transport == "stdio" and not self.command.strip(): + raise ValueError(f"MCP 服务器 {self.name} 使用 stdio 时必须填写 command") + + if self.transport == "sse" and not self.url.strip(): + raise ValueError(f"MCP 服务器 {self.name} 使用 sse 时必须填写 url") + + return super().model_post_init(context) + + +class MCPConfig(ConfigBase): + """MCP 总配置。""" + + __ui_parent__ = "maisaka" + + enable: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "zap", + }, + ) + """是否启用 MCP(Model Context Protocol)""" + + servers: list[MCPServerItemConfig] = Field( + default_factory=lambda: [], + json_schema_extra={ + "x-widget": "custom", + "x-icon": "server", + }, + ) + """_wrap_MCP 服务器配置列表""" + + def model_post_init(self, context: Optional[dict] = None) -> None: + """验证 MCP 总配置。""" + + server_names = [server.name.strip() for server in self.servers if server.name.strip()] + if len(server_names) != len(set(server_names)): + raise ValueError("MCP 配置中的服务器名称不能重复") + return super().model_post_init(context) + + class PluginRuntimeConfig(ConfigBase): """插件运行时配置类""" diff --git a/src/maisaka/chat_loop_service.py b/src/maisaka/chat_loop_service.py index 117c0368..004538e0 100644 --- a/src/maisaka/chat_loop_service.py +++ b/src/maisaka/chat_loop_service.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from io import BytesIO from time import perf_counter -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence import asyncio import random @@ -133,14 +133,14 @@ class MaisakaChatLoopService: self._prompts_loaded = True - def set_extra_tools(self, tools: List[ToolDefinitionInput]) -> None: + def set_extra_tools(self, tools: Sequence[ToolDefinitionInput]) -> None: """设置额外工具定义。 Args: tools: 兼容旧接口的额外工具定义列表。 """ - self._extra_tools = normalize_tool_options(tools) or [] + self._extra_tools = normalize_tool_options(list(tools)) or [] def set_tool_registry(self, tool_registry: ToolRegistry | None) -> None: """设置统一工具注册表。 diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py index 8d87dc79..a05c1e37 100644 --- a/src/maisaka/runtime.py +++ b/src/maisaka/runtime.py @@ -1,6 +1,5 @@ """Maisaka 非 CLI 运行时。""" -from pathlib import Path from typing import Literal, Optional import asyncio @@ -91,7 +90,7 @@ class MaisakaHeartFlowChatting: self._ensure_background_tasks_running() return - if global_config.maisaka.enable_mcp: + if global_config.mcp.enable: await self._init_mcp() self._running = True @@ -386,8 +385,7 @@ class MaisakaHeartFlowChatting: async def _init_mcp(self) -> None: """初始化 MCP 工具并注册到统一工具层。""" - config_path = Path(__file__).resolve().parents[2] / "config" / "mcp_config.json" - self._mcp_manager = await MCPManager.from_config(str(config_path)) + self._mcp_manager = await MCPManager.from_app_config(global_config.mcp) if self._mcp_manager is None: logger.info(f"{self.log_prefix} MCP 管理器不可用") return diff --git a/src/mcp_module/__init__.py b/src/mcp_module/__init__.py index ab8fa632..0fd5bee7 100644 --- a/src/mcp_module/__init__.py +++ b/src/mcp_module/__init__.py @@ -4,9 +4,10 @@ MCP (Model Context Protocol) 客户端包。 提供 MCPManager 用于管理 MCP 服务器连接、发现工具、调用工具。 用法: + from src.config.config import global_config from .manager import MCPManager - manager = await MCPManager.from_config("config/mcp_config.json") + manager = await MCPManager.from_app_config(global_config.mcp) if manager: tools = manager.get_openai_tools() # 获取 OpenAI 格式工具列表 result = await manager.call_tool(name, args) # 调用工具 diff --git a/src/mcp_module/config.py b/src/mcp_module/config.py index 7443d3c2..a9e4a432 100644 --- a/src/mcp_module/config.py +++ b/src/mcp_module/config.py @@ -1,56 +1,36 @@ -""" -MCP 配置加载与验证。 -从 config/mcp_config.json 读取 MCP 服务器定义,解析为结构化配置对象。 +"""MCP 运行时配置转换。 -配置格式示例: -{ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:/Users"], - "env": {} - }, - "remote-api": { - "url": "http://localhost:8080/sse", - "headers": {"Authorization": "Bearer xxx"} - } - } -} - -- command + args: Stdio 传输(启动子进程) -- url: SSE 传输(连接远程服务器) +负责将主程序官方配置中的 MCP 配置转换为运行时使用的结构化对象。 """ +from __future__ import annotations + from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional -import json -import os +from typing import TYPE_CHECKING -from src.cli.console import console +if TYPE_CHECKING: + from src.config.official_configs import MCPConfig -DEFAULT_MCP_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "mcp_config.json" - - -@dataclass -class MCPServerConfig: - """单个 MCP 服务器配置。""" +@dataclass(slots=True) +class MCPServerRuntimeConfig: + """单个 MCP 服务器的运行时配置。""" name: str - - # ── Stdio 传输 ── - command: Optional[str] = None + command: str = "" args: list[str] = field(default_factory=list) - env: Optional[dict[str, str]] = None - - # ── SSE 传输 ── - url: Optional[str] = None + env: dict[str, str] = field(default_factory=dict) + url: str = "" headers: dict[str, str] = field(default_factory=dict) @property def transport_type(self) -> str: - """返回传输类型: 'stdio' / 'sse' / 'unknown'。""" + """返回当前服务器的传输类型。 + + Returns: + str: ``stdio``、``sse`` 或 ``unknown``。 + """ + if self.command: return "stdio" if self.url: @@ -58,50 +38,33 @@ class MCPServerConfig: return "unknown" -def load_mcp_config(config_path: str = str(DEFAULT_MCP_CONFIG_PATH)) -> list[MCPServerConfig]: - """ - 从配置文件加载 MCP 服务器列表。 +def build_mcp_server_runtime_configs(mcp_config: "MCPConfig") -> list[MCPServerRuntimeConfig]: + """将官方 MCP 配置转换为运行时配置列表。 Args: - config_path: 配置文件路径 + mcp_config: 主程序中的 MCP 官方配置对象。 Returns: - 解析后的 MCPServerConfig 列表;文件不存在或为空时返回空列表。 + list[MCPServerRuntimeConfig]: 启用且配置完整的 MCP 服务器列表。 """ - if not os.path.isfile(config_path): + + if not mcp_config.enable: return [] - try: - with open(config_path, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError) as e: - console.print(f"[warning]⚠️ 读取 MCP 配置失败: {e}[/warning]") - return [] - - mcp_servers = data.get("mcpServers", {}) - if not isinstance(mcp_servers, dict): - console.print("[warning]⚠️ MCP 配置中的 mcpServers 格式无效[/warning]") - return [] - - configs: list[MCPServerConfig] = [] - for name, cfg in mcp_servers.items(): - if not isinstance(cfg, dict): - console.print(f"[warning]⚠️ MCP 服务器 '{name}' 配置格式无效,已跳过[/warning]") + runtime_configs: list[MCPServerRuntimeConfig] = [] + for server in mcp_config.servers: + if not server.enabled: continue - server = MCPServerConfig( - name=name, - command=cfg.get("command"), - args=cfg.get("args", []), - env=cfg.get("env"), - url=cfg.get("url"), - headers=cfg.get("headers", {}), + runtime_configs.append( + MCPServerRuntimeConfig( + name=server.name.strip(), + command=server.command.strip(), + args=[str(argument) for argument in server.args], + env={str(key): str(value) for key, value in server.env.items()}, + url=server.url.strip(), + headers={str(key): str(value) for key, value in server.headers.items()}, + ) ) - if server.transport_type == "unknown": - console.print(f"[warning]⚠️ MCP 服务器 '{name}' 缺少 command 或 url,已跳过[/warning]") - continue - - configs.append(server) - - return configs + return runtime_configs diff --git a/src/mcp_module/connection.py b/src/mcp_module/connection.py index 9b4912a0..0058d518 100644 --- a/src/mcp_module/connection.py +++ b/src/mcp_module/connection.py @@ -10,12 +10,12 @@ from src.core.tooling import ToolExecutionResult from src.cli.console import console -from .config import MCPServerConfig +from .config import MCPServerRuntimeConfig # ──────────────────── MCP SDK 可选导入 ──────────────────── # # mcp 是可选依赖。如果未安装,MCP_AVAILABLE = False, -# MCPManager.from_config() 会检测到并返回 None,不影响主程序运行。 +# MCPManager.from_app_config() 会检测到并返回 None,不影响主程序运行。 try: from mcp import ClientSession @@ -44,15 +44,20 @@ except ImportError: class MCPConnection: - """ - 管理单个 MCP 服务器的连接生命周期。 + """管理单个 MCP 服务器的连接生命周期。 支持两种传输方式: - Stdio: 启动子进程,通过 stdin/stdout 通信 - SSE: 连接远程 HTTP SSE 端点 """ - def __init__(self, config: MCPServerConfig): + def __init__(self, config: MCPServerRuntimeConfig) -> None: + """初始化单个 MCP 连接。 + + Args: + config: 当前服务器的运行时配置。 + """ + self.config = config self.session: Optional[Any] = None # mcp.ClientSession self.tools: list = [] # mcp Tool objects diff --git a/src/mcp_module/manager.py b/src/mcp_module/manager.py index 218d08e4..7dbb8c3c 100644 --- a/src/mcp_module/manager.py +++ b/src/mcp_module/manager.py @@ -3,7 +3,7 @@ MaiSaka - MCP 管理器 管理所有 MCP 服务器连接,提供统一的工具发现与调用接口。 """ -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional from src.cli.console import console from src.core.tooling import ( @@ -13,9 +13,12 @@ from src.core.tooling import ( build_tool_detailed_description, ) -from .config import DEFAULT_MCP_CONFIG_PATH, MCPServerConfig, load_mcp_config +from .config import MCPServerRuntimeConfig, build_mcp_server_runtime_configs from .connection import MCPConnection, MCP_AVAILABLE +if TYPE_CHECKING: + from src.config.official_configs import MCPConfig + # 内置工具名称集合 —— MCP 工具不允许与这些名称冲突 BUILTIN_TOOL_NAMES = frozenset( { @@ -31,37 +34,38 @@ BUILTIN_TOOL_NAMES = frozenset( class MCPManager: - """ - MCP 服务器连接管理器。 + """MCP 服务器连接管理器。 职责: - - 根据配置文件连接所有 MCP 服务器 + - 根据主程序官方配置连接所有 MCP 服务器 - 将 MCP 工具转换为 OpenAI function calling 格式 - 路由工具调用到正确的 MCP 服务器 - 统一管理连接生命周期 """ - def __init__(self): + def __init__(self) -> None: + """初始化 MCP 管理器。""" + self._connections: dict[str, MCPConnection] = {} # server_name → connection self._tool_to_server: dict[str, str] = {} # tool_name → server_name # ──────── 工厂方法 ──────── @classmethod - async def from_config( + async def from_app_config( cls, - config_path: str = str(DEFAULT_MCP_CONFIG_PATH), + mcp_config: "MCPConfig", ) -> Optional["MCPManager"]: """ - 从配置文件创建并初始化 MCPManager。 + 从官方配置创建并初始化 MCPManager。 Args: - config_path: mcp_config.json 文件路径 + mcp_config: 主程序中的 MCP 配置对象。 Returns: - 初始化完成的 MCPManager;无配置或全部连接失败时返回 None。 + 初始化完成的 MCPManager;无可用配置或全部连接失败时返回 None。 """ - configs = load_mcp_config(config_path) + configs = build_mcp_server_runtime_configs(mcp_config) if not configs: return None @@ -80,7 +84,7 @@ class MCPManager: # ──────── 连接管理 ──────── - async def _connect_all(self, configs: list[MCPServerConfig]) -> None: + async def _connect_all(self, configs: list[MCPServerRuntimeConfig]) -> None: """连接所有配置的 MCP 服务器,跳过失败的连接。""" for cfg in configs: conn = MCPConnection(cfg)