From 6c89f1eb4bcdfff8981417c328a0b6998d5c4068 Mon Sep 17 00:00:00 2001 From: cyt-666 Date: Fri, 31 Jul 2026 22:31:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20MCP=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8A=A0=E8=BD=BD=E6=8F=92=E4=BB=B6=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20(#6214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/tools/manager.py | 58 ++++++++++++++++---- app/core/plugin.py | 72 +++++++++++++++---------- docs/mcp-api.md | 8 ++- skills/moviepilot-api/SKILL.md | 5 ++ skills/moviepilot-cli/SKILL.md | 4 ++ tests/test_mcp_plugin_tools.py | 98 ++++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 tests/test_mcp_plugin_tools.py diff --git a/app/agent/tools/manager.py b/app/agent/tools/manager.py index 17ec6ef9..95320cbf 100644 --- a/app/agent/tools/manager.py +++ b/app/agent/tools/manager.py @@ -1,9 +1,11 @@ import json +import threading import uuid from typing import Any, Dict, List, Optional from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent from app.agent.tools.factory import MoviePilotToolFactory +from app.core.plugin import PluginManager from app.log import logger @@ -40,27 +42,59 @@ class MoviePilotToolsManager: self.session_id = session_id self.is_admin = is_admin self.tools: List[Any] = [] + self._tools_lock = threading.Lock() + self._plugin_agent_tools_revision = -1 self._load_tools() - def _load_tools(self): + def _load_tools(self) -> None: """ 加载所有MoviePilot工具 """ try: - # 创建工具实例 - self.tools = MoviePilotToolFactory.create_tools( - session_id=self.session_id, - user_id=self.user_id, - channel=None, - source="api", - username="API Client", - stream_handler=None, - agent_context={"is_admin": self.is_admin}, - ) + plugin_manager = PluginManager() + while True: + plugin_tools_revision = ( + plugin_manager.get_plugin_agent_tools_revision() + ) + tools = MoviePilotToolFactory.create_tools( + session_id=self.session_id, + user_id=self.user_id, + channel=None, + source="api", + username="API Client", + stream_handler=None, + agent_context={"is_admin": self.is_admin}, + ) + if ( + plugin_tools_revision + == plugin_manager.get_plugin_agent_tools_revision() + ): + break + self.tools = tools + self._plugin_agent_tools_revision = plugin_tools_revision logger.info(f"成功加载 {len(self.tools)} 个工具") except Exception as e: logger.error(f"加载工具失败: {e}", exc_info=True) self.tools = [] + self._plugin_agent_tools_revision = -1 + + def _ensure_tools_current(self) -> None: + """ + 在插件工具注册表变化后惰性刷新工具实例。 + """ + plugin_manager = PluginManager() + if ( + self._plugin_agent_tools_revision + == plugin_manager.get_plugin_agent_tools_revision() + ): + return + with self._tools_lock: + if ( + self._plugin_agent_tools_revision + == plugin_manager.get_plugin_agent_tools_revision() + ): + return + self._load_tools() def list_tools(self) -> List[ToolDefinition]: """ @@ -69,6 +103,7 @@ class MoviePilotToolsManager: Returns: 工具定义列表 """ + self._ensure_tools_current() tools_list = [] for tool in self.tools: if getattr(tool, "_require_admin", False) and not self.is_admin: @@ -102,6 +137,7 @@ class MoviePilotToolsManager: Returns: 工具实例,如果未找到返回None """ + self._ensure_tools_current() for tool in self.tools: if tool.name == tool_name: return tool diff --git a/app/core/plugin.py b/app/core/plugin.py index d55765c3..8fc8954d 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -58,6 +58,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 插件智能体工具注册表缓存,插件启停或配置生效时主动失效。 self._plugin_agent_tools_cache: Dict[str, List[Dict[str, Any]]] = {} self._plugin_agent_tools_cache_lock = threading.Lock() + self._plugin_agent_tools_revision: int = 0 # 开发者模式监测插件修改 if settings.DEV or settings.PLUGIN_AUTO_RELOAD: self.__start_monitor() @@ -143,6 +144,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ with self._plugin_agent_tools_cache_lock: self._plugin_agent_tools_cache.clear() + self._plugin_agent_tools_revision += 1 + + def get_plugin_agent_tools_revision(self) -> int: + """ + 获取插件智能体工具注册表版本号。 + """ + with self._plugin_agent_tools_cache_lock: + return self._plugin_agent_tools_revision def stop(self, pid: Optional[str] = None): """ @@ -1002,35 +1011,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): }] """ cache_key = pid or "__all__" - with self._plugin_agent_tools_cache_lock: - cached_tools = self._plugin_agent_tools_cache.get(cache_key) - if cached_tools is not None: - return self._copy_plugin_agent_tools(cached_tools) + while True: + with self._plugin_agent_tools_cache_lock: + cache_revision = self._plugin_agent_tools_revision + cached_tools = self._plugin_agent_tools_cache.get(cache_key) + if cached_tools is not None: + return self._copy_plugin_agent_tools(cached_tools) - ret_tools = [] - # 创建字典快照避免并发修改 - running_plugins_snapshot = dict(self._running_plugins) - for plugin_id, plugin in running_plugins_snapshot.items(): - if pid and pid != plugin_id: - continue - if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(plugin.get_agent_tools): - try: - if not plugin.get_state(): - continue - tools = plugin.get_agent_tools() - if tools: - ret_tools.append({ - "plugin_id": plugin_id, - "plugin_name": plugin.plugin_name, - "tools": tools - }) - except Exception as e: - logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}") - with self._plugin_agent_tools_cache_lock: - self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools( - ret_tools - ) - return ret_tools + ret_tools = [] + # 创建字典快照避免并发修改 + running_plugins_snapshot = dict(self._running_plugins) + for plugin_id, plugin in running_plugins_snapshot.items(): + if pid and pid != plugin_id: + continue + if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method( + plugin.get_agent_tools + ): + try: + if not plugin.get_state(): + continue + tools = plugin.get_agent_tools() + if tools: + ret_tools.append({ + "plugin_id": plugin_id, + "plugin_name": plugin.plugin_name, + "tools": tools + }) + except Exception as e: + logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}") + with self._plugin_agent_tools_cache_lock: + if cache_revision != self._plugin_agent_tools_revision: + # 插件状态在注册表构建期间发生变化,重新读取以避免写回过期快照。 + continue + self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools( + ret_tools + ) + return ret_tools @staticmethod def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str: diff --git a/docs/mcp-api.md b/docs/mcp-api.md index e352ce32..0e3a5140 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -31,6 +31,12 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K - `tools/call`: 调用特定工具。 - `ping`: 连接存活检测。 +### 动态插件工具 + +`tools/list` 会同时返回 MoviePilot 内置工具和已启用插件通过 `get_agent_tools()` 声明的工具。插件启动、停止、重载或配置生效后,MCP 工具管理器会在下一次列出或调用工具时按注册表版本惰性刷新,避免继续暴露已移除的工具或遗漏新工具。 + +MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。如果客户端缓存了工具列表,插件状态变化后需要让客户端重新请求 `tools/list`;无法手动刷新的客户端应重新连接 MCP 服务或新建会话。 + --- ## 4. 客户端配置示例 @@ -231,7 +237,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 获取所有可用的MCP工具列表。 -工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。 +内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。 媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index e0f13253..376f80ae 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -505,6 +505,11 @@ The two list endpoints return local cache totals plus `shared_recognized` and | GET | `/api/v1/mcp/tools/{tool_name}` | Get tool definition | | GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema | +The exposed tool list is dynamic: it includes tools declared by enabled plugins +and is refreshed lazily after plugin startup, shutdown, reload, or configuration +activation. Clients that cache MCP metadata must request `tools/list` again or +reconnect after a plugin lifecycle change. + ### Agent MCP Client (3 endpoints) | Method | Path | Description | diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index e99e5dfc..98aa40a4 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -45,6 +45,10 @@ List all available commands: `moviepilot tool list` Show parameters and usage for a specific command: `moviepilot tool show ` +The tool list includes tools declared by enabled plugins. Re-run `tool list` and +`tool show` after a plugin is enabled, disabled, reloaded, or reconfigured so the +command selection uses the refreshed runtime registry. + Always run `show ` before calling a command — parameter names are not inferable, do not guess. ## Command Groups diff --git a/tests/test_mcp_plugin_tools.py b/tests/test_mcp_plugin_tools.py new file mode 100644 index 00000000..c28facd3 --- /dev/null +++ b/tests/test_mcp_plugin_tools.py @@ -0,0 +1,98 @@ +import asyncio +import json +from types import SimpleNamespace +from typing import Iterator +from unittest.mock import patch + +import pytest + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.factory import MoviePilotToolFactory +from app.agent.tools.manager import MoviePilotToolsManager +from app.api.endpoints import mcp +from app.core.plugin import PluginManager +from app.utils.singleton import Singleton + + +class DemoPluginTool(MoviePilotTool): + """测试用插件 MCP 工具。""" + + name: str = "demo_plugin_tool" + description: str = "测试插件动态注册的 MCP 工具" + + async def run(self, **kwargs) -> str: + """返回固定测试结果。""" + return "plugin-ok" + + +@pytest.fixture +def plugin_manager() -> Iterator[PluginManager]: + """构造隔离的插件管理器并在测试后恢复原单例。""" + singleton_key = (PluginManager, (), frozenset()) + previous_instance = Singleton._instances.pop(singleton_key, None) + manager = PluginManager() + yield manager + Singleton._instances.pop(singleton_key, None) + if previous_instance is not None: + Singleton._instances[singleton_key] = previous_instance + + +def _build_plugin() -> SimpleNamespace: + """构造声明一个 Agent 工具的已启用插件。""" + return SimpleNamespace( + plugin_name="Demo Plugin", + get_state=lambda: True, + get_agent_tools=lambda: [DemoPluginTool], + ) + + +def test_mcp_refreshes_tools_after_plugin_lifecycle_change( + plugin_manager: PluginManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """MCP 管理器应发现初始化后新增的插件工具,并在插件移除后停止暴露。""" + with patch.object( + MoviePilotToolFactory, + "_get_builtin_tool_classes", + return_value=[], + ): + tool_manager = MoviePilotToolsManager( + session_id="mcp-plugin-test", + user_id="api_user", + ) + monkeypatch.setattr(mcp, "moviepilot_tool_manager", tool_manager) + + assert asyncio.run(mcp.handle_tools_list()) == {"tools": []} + + plugin_manager.running_plugins["DemoPlugin"] = _build_plugin() + plugin_manager.clear_plugin_agent_tools_cache() + + listed_tools = asyncio.run(mcp.handle_tools_list())["tools"] + assert [tool["name"] for tool in listed_tools] == ["demo_plugin_tool"] + + call_result = asyncio.run( + mcp.handle_tools_call( + { + "name": "demo_plugin_tool", + "arguments": {}, + } + ) + ) + assert call_result == { + "content": [{"type": "text", "text": "plugin-ok"}] + } + + plugin_manager.running_plugins.pop("DemoPlugin") + plugin_manager.clear_plugin_agent_tools_cache() + + assert asyncio.run(mcp.handle_tools_list()) == {"tools": []} + missing_result = asyncio.run( + mcp.handle_tools_call( + { + "name": "demo_plugin_tool", + "arguments": {}, + } + ) + ) + missing_payload = json.loads(missing_result["content"][0]["text"]) + assert "未找到" in missing_payload["error"]