mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 15:34:31 +08:00
fix: 修复 MCP 动态加载插件工具 (#6214)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 及通用主身份。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
98
tests/test_mcp_plugin_tools.py
Normal file
98
tests/test_mcp_plugin_tools.py
Normal 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"]
|
||||
Reference in New Issue
Block a user