mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
fix: 修复 MCP 动态加载插件工具 (#6214)
This commit is contained in:
+47
-11
@@ -1,9 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
|
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
|
||||||
from app.agent.tools.factory import MoviePilotToolFactory
|
from app.agent.tools.factory import MoviePilotToolFactory
|
||||||
|
from app.core.plugin import PluginManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -40,27 +42,59 @@ class MoviePilotToolsManager:
|
|||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.is_admin = is_admin
|
self.is_admin = is_admin
|
||||||
self.tools: List[Any] = []
|
self.tools: List[Any] = []
|
||||||
|
self._tools_lock = threading.Lock()
|
||||||
|
self._plugin_agent_tools_revision = -1
|
||||||
self._load_tools()
|
self._load_tools()
|
||||||
|
|
||||||
def _load_tools(self):
|
def _load_tools(self) -> None:
|
||||||
"""
|
"""
|
||||||
加载所有MoviePilot工具
|
加载所有MoviePilot工具
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 创建工具实例
|
plugin_manager = PluginManager()
|
||||||
self.tools = MoviePilotToolFactory.create_tools(
|
while True:
|
||||||
session_id=self.session_id,
|
plugin_tools_revision = (
|
||||||
user_id=self.user_id,
|
plugin_manager.get_plugin_agent_tools_revision()
|
||||||
channel=None,
|
)
|
||||||
source="api",
|
tools = MoviePilotToolFactory.create_tools(
|
||||||
username="API Client",
|
session_id=self.session_id,
|
||||||
stream_handler=None,
|
user_id=self.user_id,
|
||||||
agent_context={"is_admin": self.is_admin},
|
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)} 个工具")
|
logger.info(f"成功加载 {len(self.tools)} 个工具")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"加载工具失败: {e}", exc_info=True)
|
logger.error(f"加载工具失败: {e}", exc_info=True)
|
||||||
self.tools = []
|
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]:
|
def list_tools(self) -> List[ToolDefinition]:
|
||||||
"""
|
"""
|
||||||
@@ -69,6 +103,7 @@ class MoviePilotToolsManager:
|
|||||||
Returns:
|
Returns:
|
||||||
工具定义列表
|
工具定义列表
|
||||||
"""
|
"""
|
||||||
|
self._ensure_tools_current()
|
||||||
tools_list = []
|
tools_list = []
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
if getattr(tool, "_require_admin", False) and not self.is_admin:
|
if getattr(tool, "_require_admin", False) and not self.is_admin:
|
||||||
@@ -102,6 +137,7 @@ class MoviePilotToolsManager:
|
|||||||
Returns:
|
Returns:
|
||||||
工具实例,如果未找到返回None
|
工具实例,如果未找到返回None
|
||||||
"""
|
"""
|
||||||
|
self._ensure_tools_current()
|
||||||
for tool in self.tools:
|
for tool in self.tools:
|
||||||
if tool.name == tool_name:
|
if tool.name == tool_name:
|
||||||
return tool
|
return tool
|
||||||
|
|||||||
+44
-28
@@ -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: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
self._plugin_agent_tools_cache_lock = threading.Lock()
|
self._plugin_agent_tools_cache_lock = threading.Lock()
|
||||||
|
self._plugin_agent_tools_revision: int = 0
|
||||||
# 开发者模式监测插件修改
|
# 开发者模式监测插件修改
|
||||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||||
self.__start_monitor()
|
self.__start_monitor()
|
||||||
@@ -143,6 +144,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
with self._plugin_agent_tools_cache_lock:
|
with self._plugin_agent_tools_cache_lock:
|
||||||
self._plugin_agent_tools_cache.clear()
|
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):
|
def stop(self, pid: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
@@ -1002,35 +1011,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
}]
|
}]
|
||||||
"""
|
"""
|
||||||
cache_key = pid or "__all__"
|
cache_key = pid or "__all__"
|
||||||
with self._plugin_agent_tools_cache_lock:
|
while True:
|
||||||
cached_tools = self._plugin_agent_tools_cache.get(cache_key)
|
with self._plugin_agent_tools_cache_lock:
|
||||||
if cached_tools is not None:
|
cache_revision = self._plugin_agent_tools_revision
|
||||||
return self._copy_plugin_agent_tools(cached_tools)
|
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 = []
|
ret_tools = []
|
||||||
# 创建字典快照避免并发修改
|
# 创建字典快照避免并发修改
|
||||||
running_plugins_snapshot = dict(self._running_plugins)
|
running_plugins_snapshot = dict(self._running_plugins)
|
||||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||||
if pid and pid != plugin_id:
|
if pid and pid != plugin_id:
|
||||||
continue
|
continue
|
||||||
if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(plugin.get_agent_tools):
|
if hasattr(plugin, "get_agent_tools") and ObjectUtils.check_method(
|
||||||
try:
|
plugin.get_agent_tools
|
||||||
if not plugin.get_state():
|
):
|
||||||
continue
|
try:
|
||||||
tools = plugin.get_agent_tools()
|
if not plugin.get_state():
|
||||||
if tools:
|
continue
|
||||||
ret_tools.append({
|
tools = plugin.get_agent_tools()
|
||||||
"plugin_id": plugin_id,
|
if tools:
|
||||||
"plugin_name": plugin.plugin_name,
|
ret_tools.append({
|
||||||
"tools": tools
|
"plugin_id": plugin_id,
|
||||||
})
|
"plugin_name": plugin.plugin_name,
|
||||||
except Exception as e:
|
"tools": tools
|
||||||
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}")
|
})
|
||||||
with self._plugin_agent_tools_cache_lock:
|
except Exception as e:
|
||||||
self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools(
|
logger.error(f"获取插件 {plugin_id} 智能体工具出错:{str(e)}")
|
||||||
ret_tools
|
with self._plugin_agent_tools_cache_lock:
|
||||||
)
|
if cache_revision != self._plugin_agent_tools_revision:
|
||||||
return ret_tools
|
# 插件状态在注册表构建期间发生变化,重新读取以避免写回过期快照。
|
||||||
|
continue
|
||||||
|
self._plugin_agent_tools_cache[cache_key] = self._copy_plugin_agent_tools(
|
||||||
|
ret_tools
|
||||||
|
)
|
||||||
|
return ret_tools
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
|
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
|
||||||
|
|||||||
+7
-1
@@ -31,6 +31,12 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
|
|||||||
- `tools/call`: 调用特定工具。
|
- `tools/call`: 调用特定工具。
|
||||||
- `ping`: 连接存活检测。
|
- `ping`: 连接存活检测。
|
||||||
|
|
||||||
|
### 动态插件工具
|
||||||
|
|
||||||
|
`tools/list` 会同时返回 MoviePilot 内置工具和已启用插件通过 `get_agent_tools()` 声明的工具。插件启动、停止、重载或配置生效后,MCP 工具管理器会在下一次列出或调用工具时按注册表版本惰性刷新,避免继续暴露已移除的工具或遗漏新工具。
|
||||||
|
|
||||||
|
MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。如果客户端缓存了工具列表,插件状态变化后需要让客户端重新请求 `tools/list`;无法手动刷新的客户端应重新连接 MCP 服务或新建会话。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 客户端配置示例
|
## 4. 客户端配置示例
|
||||||
@@ -231,7 +237,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
|||||||
|
|
||||||
获取所有可用的MCP工具列表。
|
获取所有可用的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 及通用主身份。
|
媒体相关 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}` | Get tool definition |
|
||||||
| GET | `/api/v1/mcp/tools/{tool_name}/schema` | Get tool input schema |
|
| 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)
|
### Agent MCP Client (3 endpoints)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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>`
|
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.
|
Always run `show <command>` before calling a command — parameter names are not inferable, do not guess.
|
||||||
|
|
||||||
## Command Groups
|
## Command Groups
|
||||||
|
|||||||
@@ -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