File size: 13,630 Bytes
6f89625
 
10b8d56
 
 
 
 
6f89625
 
 
 
 
d0c18f0
 
6f89625
10b8d56
 
6f89625
 
 
 
 
 
 
10b8d56
 
 
 
 
 
 
 
6f89625
10b8d56
 
 
 
 
 
 
6f89625
 
 
 
 
 
 
10b8d56
 
 
 
 
d0c18f0
6f89625
10b8d56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f89625
 
10b8d56
 
6f89625
d0c18f0
 
 
6f89625
 
 
 
 
 
 
 
 
 
 
 
 
d0c18f0
 
 
 
 
 
 
 
 
 
 
 
6f89625
 
 
 
 
 
 
 
 
 
 
 
 
 
cc826a1
e5e756a
6f89625
 
 
 
 
 
 
 
 
 
10b8d56
 
 
 
 
 
 
 
 
 
 
6f89625
 
 
 
 
 
 
10b8d56
 
 
 
 
 
 
6f89625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10b8d56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f89625
 
10b8d56
6f89625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
"""
插件MCP工具动态注册器 - 扫描插件并注册MCP工具

扩展功能:
- 工具可用性绑定插件生命周期
- 最近错误记录
- 元数据完整性检查
"""

import importlib.util
import sys
import logging
import functools
import inspect
from pathlib import Path
from typing import Dict, Any, List, TYPE_CHECKING, Callable, Optional
from datetime import datetime

if TYPE_CHECKING:
    from mcp.server import FastMCP

logger = logging.getLogger(__name__)


class MCPToolAvailability:
    """MCP 工具可用状态常量"""

    AVAILABLE = "available"
    UNAVAILABLE = "unavailable"
    INCOMPLETE = "incomplete"


class PluginMCPRegistry:
    """插件MCP工具动态注册器

    扩展功能:
    - 工具可用性绑定插件生命周期
    - 最近错误记录
    - 元数据完整性检查
    """

    def __init__(self, mcp_server: "FastMCP"):
        self.mcp = mcp_server
        self.registered_tools: Dict[str, Dict[str, Any]] = {}
        self._tool_wrappers: Dict[str, Callable] = {}

    def _create_tool_wrapper(self, tool_name: str, original_func: Callable) -> Callable:
        """创建工具调用包装器,保持原始函数签名用于 schema 生成。

        包装器在调用前查询插件状态和依赖状态,不可用时直接返回结构化错误。
        捕获参数错误和运行异常,写入最近错误记录。
        """
        @functools.wraps(original_func)
        async def wrapper(*args, **kwargs):
            # 检查工具是否在注册表中
            tool_info = self.registered_tools.get(tool_name)
            if not tool_info:
                return {
                    "success": False,
                    "error": f"工具 {tool_name} 不存在",
                    "error_code": "TOOL_NOT_FOUND",
                }

            # 检查工具可用性
            availability = tool_info.get("availability", MCPToolAvailability.AVAILABLE)
            if availability == MCPToolAvailability.UNAVAILABLE:
                reason = tool_info.get("unavailable_reason", "工具不可用")
                return {
                    "success": False,
                    "error": f"工具 {tool_name} 不可用:{reason}",
                    "error_code": "TOOL_UNAVAILABLE",
                    "unavailable_reason": reason,
                }

            # 记录调用时间
            tool_info["last_called_at"] = datetime.now()

            try:
                # 调用原始函数
                result = await original_func(*args, **kwargs)
                return result
            except TypeError as e:
                # 参数错误
                error_msg = f"参数错误:{str(e)}"
                tool_info["last_error"] = {
                    "error_code": "INVALID_PARAMETERS",
                    "message": error_msg,
                    "occurred_at": datetime.now().isoformat(),
                }
                logger.error(f"工具 {tool_name} 参数错误: {e}")
                return {
                    "success": False,
                    "error": error_msg,
                    "error_code": "INVALID_PARAMETERS",
                }
            except Exception as e:
                # 运行时错误
                error_msg = f"运行错误:{str(e)}"
                tool_info["last_error"] = {
                    "error_code": "RUNTIME_ERROR",
                    "message": error_msg,
                    "occurred_at": datetime.now().isoformat(),
                }
                logger.error(f"工具 {tool_name} 运行错误: {e}")
                return {
                    "success": False,
                    "error": error_msg,
                    "error_code": "RUNTIME_ERROR",
                }

        # 保持原始函数签名
        wrapper.__signature__ = inspect.signature(original_func)
        return wrapper

    def scan_and_register_all(self, plugins_dir: Path) -> int:
        """扫描所有插件并注册MCP工具。"""
        total = 0
        for plugin_dir in plugins_dir.iterdir():
            if plugin_dir.is_dir() and not plugin_dir.name.startswith('_'):
                count = self.register_plugin_tools(plugin_dir)
                total += count
                if count > 0:
                    logger.info(f"插件 {plugin_dir.name} 注册了 {count} 个MCP工具")
        return total

    def register_plugin_tools_by_name(self, plugins_dir: Path, plugin_names: List[str]) -> int:
        """仅注册指定插件的 MCP 工具"""
        total = 0
        for plugin_name in plugin_names:
            plugin_dir = plugins_dir / plugin_name
            if plugin_dir.is_dir() and not plugin_dir.name.startswith('_'):
                count = self.register_plugin_tools(plugin_dir)
                total += count
                if count > 0:
                    logger.info(f"插件 {plugin_name} 注册了 {count} 个MCP工具")
        return total

    def register_plugin_tools(self, plugin_dir: Path) -> int:
        """注册单个插件的MCP工具。"""
        mcp_file = plugin_dir / "mcp.py"
        if not mcp_file.exists():
            return 0

        plugin_name = plugin_dir.name
        module = self._import_module(mcp_file, plugin_name)
        if not module:
            return 0

        tools = self._scan_tools(module)
        count = 0
        for tool_def in tools:
            # 直接使用 decorator 中定义的名称(约定:{插件名}-{工具名})
            full_name = tool_def['name']
            try:
                wrapped_func = self._create_tool_wrapper(full_name, tool_def['func'])
                self._tool_wrappers[full_name] = wrapped_func

                self.mcp.tool(
                    name=full_name,
                    title=tool_def.get('title'),
                    description=tool_def.get('description'),
                )(wrapped_func)

                # 检查元数据完整性
                incomplete_reasons = []
                if not tool_def.get('title'):
                    incomplete_reasons.append("缺少标题")
                if not tool_def.get('description'):
                    incomplete_reasons.append("缺少描述")

                availability = MCPToolAvailability.AVAILABLE
                if incomplete_reasons:
                    availability = MCPToolAvailability.INCOMPLETE

                self.registered_tools[full_name] = {
                    "plugin": plugin_name,
                    "tool": tool_def['name'],
                    "input_model": tool_def.get('input_model'),
                    "output_model": tool_def.get('output_model'),
                    "title": tool_def.get('title'),
                    "description": tool_def.get('description'),
                    # 新增字段
                    "availability": availability,
                    "unavailable_reason": None,
                    "incomplete_reasons": incomplete_reasons if incomplete_reasons else None,
                    "last_error": None,
                    "last_called_at": None,
                    "registered_at": datetime.now().isoformat(),
                }
                count += 1
            except Exception as e:
                logger.error(f"注册工具 {full_name} 失败: {e}")

        return count

    def _import_module(self, mcp_file: Path, plugin_name: str):
        """动态导入模块。"""
        try:
            spec = importlib.util.spec_from_file_location(
                f"plugins.{plugin_name}.mcp", mcp_file
            )
            if not spec or not spec.loader:
                return None
            module = importlib.util.module_from_spec(spec)
            sys.modules[spec.name] = module
            spec.loader.exec_module(module)
            return module
        except Exception as e:
            logger.error(f"导入模块 {mcp_file} 失败: {e}")
            return None

    def _scan_tools(self, module) -> List[Dict]:
        """扫描模块中的工具定义。"""
        tools = []
        for attr_name in dir(module):
            attr = getattr(module, attr_name)
            if callable(attr) and hasattr(attr, '_mcp_tool_def'):
                tools.append(attr._mcp_tool_def)
        return tools

    def get_tool_docs(self, plugin_name: str = None) -> Dict:
        """获取工具文档。"""
        if plugin_name:
            return {
                name: info for name, info in self.registered_tools.items()
                if info['plugin'] == plugin_name
            }
        return self.registered_tools.copy()

    def unregister_plugin_tools(self, plugin_name: str) -> int:
        """注销插件的MCP工具。"""
        tools_to_remove = [
            name for name, info in self.registered_tools.items()
            if info['plugin'] == plugin_name
        ]
        for tool_name in tools_to_remove:
            del self.registered_tools[tool_name]
        logger.info(f"已注销插件 {plugin_name}{len(tools_to_remove)} 个工具")
        return len(tools_to_remove)

    def get_plugin_tools(self, plugin_name: str) -> List[str]:
        """获取插件的所有工具名称。"""
        return [
            name for name, info in self.registered_tools.items()
            if info['plugin'] == plugin_name
        ]

    def set_tool_availability(
        self,
        tool_name: str,
        availability: str,
        unavailable_reason: Optional[str] = None,
    ) -> bool:
        """设置工具可用状态

        Args:
            tool_name: 工具全名
            availability: 可用状态
            unavailable_reason: 不可用原因

        Returns:
            是否设置成功
        """
        tool_info = self.registered_tools.get(tool_name)
        if not tool_info:
            return False

        tool_info["availability"] = availability
        tool_info["unavailable_reason"] = unavailable_reason
        return True

    def set_plugin_tools_availability(
        self,
        plugin_name: str,
        availability: str,
        unavailable_reason: Optional[str] = None,
    ) -> int:
        """设置插件所有工具的可用状态

        Args:
            plugin_name: 插件名
            availability: 可用状态
            unavailable_reason: 不可用原因

        Returns:
            更新的工具数量
        """
        count = 0
        for tool_name, tool_info in self.registered_tools.items():
            if tool_info["plugin"] == plugin_name:
                tool_info["availability"] = availability
                tool_info["unavailable_reason"] = unavailable_reason
                count += 1
        return count

    def get_mcp_status(self) -> Dict[str, Any]:
        """获取 MCP 服务状态

        Returns:
            包含服务状态、工具总数、按插件分组、最近错误的字典
        """
        total_tools = len(self.registered_tools)
        available_tools = sum(
            1 for info in self.registered_tools.values()
            if info.get("availability") == MCPToolAvailability.AVAILABLE
        )
        unavailable_tools = sum(
            1 for info in self.registered_tools.values()
            if info.get("availability") == MCPToolAvailability.UNAVAILABLE
        )
        incomplete_tools = sum(
            1 for info in self.registered_tools.values()
            if info.get("availability") == MCPToolAvailability.INCOMPLETE
        )

        # 按插件分组
        groups = {}
        for tool_name, tool_info in self.registered_tools.items():
            plugin_name = tool_info["plugin"]
            if plugin_name not in groups:
                groups[plugin_name] = {
                    "plugin_name": plugin_name,
                    "tools": [],
                }
            groups[plugin_name]["tools"].append({
                "name": tool_name,
                "title": tool_info.get("title"),
                "description": tool_info.get("description"),
                "availability": tool_info.get("availability"),
                "unavailable_reason": tool_info.get("unavailable_reason"),
                "incomplete_reasons": tool_info.get("incomplete_reasons"),
                "last_error": tool_info.get("last_error"),
                "last_called_at": tool_info.get("last_called_at"),
            })

        # 收集最近错误
        recent_errors = []
        for tool_name, tool_info in self.registered_tools.items():
            if tool_info.get("last_error"):
                recent_errors.append({
                    "tool_name": tool_name,
                    "plugin_name": tool_info["plugin"],
                    **tool_info["last_error"],
                })

        return {
            "service_available": True,
            "total_tools": total_tools,
            "available_tools": available_tools,
            "unavailable_tools": unavailable_tools,
            "incomplete_tools": incomplete_tools,
            "groups": list(groups.values()),
            "recent_errors": recent_errors,
        }


# 全局单例
_plugin_registry: Optional[PluginMCPRegistry] = None


def get_plugin_registry() -> PluginMCPRegistry:
    """获取全局插件注册器实例。"""
    global _plugin_registry
    if _plugin_registry is None:
        raise RuntimeError("PluginMCPRegistry 未初始化,请先调用 init_plugin_registry(mcp)")
    return _plugin_registry


def init_plugin_registry(mcp_server: "FastMCP") -> PluginMCPRegistry:
    """初始化全局插件注册器。"""
    global _plugin_registry
    _plugin_registry = PluginMCPRegistry(mcp_server)
    return _plugin_registry