fix: 修复 MCP 动态加载插件工具 (#6214)

This commit is contained in:
cyt-666
2026-07-31 22:31:46 +08:00
committed by GitHub
parent 2310a3a456
commit 6c89f1eb4b
6 changed files with 205 additions and 40 deletions

View File

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

View File

@@ -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:

View File

@@ -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 及通用主身份。

View File

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

View File

@@ -45,6 +45,10 @@ List all available commands: `moviepilot tool list`
Show parameters and usage for a specific command: `moviepilot tool show <command>`
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 <command>` before calling a command — parameter names are not inferable, do not guess.
## Command Groups

View File

@@ -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"]