"""MCP Publisher — 将 Skill / 专家团队发布为 MCP 工具 U14: 支持把已注册的 Skill 或专家团队封装成 ``Tool``,注册到 ``PublisherRegistry`` 后即可通过 MCP 端点(``/api/v1/mcp/tools/list``、 ``/tools/call``、JSON-RPC)对外暴露,供外部系统调用。 设计要点(lazy MVP): - ``SkillMCPAdapter`` / ``TeamMCPAdapter`` 通过可选的 ``executor`` 回调执行真正的技能/团队逻辑;未配置时返回错误 dict,不阻塞发布。 - 危险工具(terminal/shell/file_*)默认拒绝发布,需显式 ``allow_dangerous=True`` 才能放行——防止把高危能力意外暴露给外部系统。 """ from __future__ import annotations import logging from typing import Any, Awaitable, Callable from agentkit.skills.base import Skill from agentkit.tools.base import Tool logger = logging.getLogger(__name__) # 危险工具名集合——这些工具若被发布到 MCP,外部系统可直接操作终端/文件系统, # 故默认拒绝发布,需管理员显式 opt-in。ponytail: 集合较小,硬编码即可。 _DANGEROUS_TOOL_NAMES: frozenset[str] = frozenset( {"terminal", "shell", "file_write", "file_read", "file_delete"} ) # 执行器签名:(skill_or_team_name, input_text) -> 结果 dict SkillExecutor = Callable[[str, str], Awaitable[dict[str, Any]]] class PublisherRegistry: """已发布 MCP 工具的内存注册表。 进程内注册表——重启后丢失(MVP 不持久化,足够当前场景)。 按工具名去重,重复注册抛 ``ValueError``。 """ def __init__(self) -> None: self._tools: dict[str, Tool] = {} def register(self, tool: Tool) -> None: """注册一个已发布工具。同名工具已存在则抛 ValueError。""" if tool.name in self._tools: raise ValueError(f"Tool '{tool.name}' already published") self._tools[tool.name] = tool logger.info(f"MCP tool published: {tool.name}") def unregister(self, name: str) -> bool: """注销已发布工具。成功返回 True,不存在返回 False。""" if name in self._tools: del self._tools[name] logger.info(f"MCP tool unpublished: {name}") return True return False def list_published(self) -> list[Tool]: """返回所有已发布工具(按注册顺序)。""" return list(self._tools.values()) def get(self, name: str) -> Tool | None: """按名获取已发布工具,不存在返回 None。""" return self._tools.get(name) class SkillMCPAdapter(Tool): """将 Skill 封装为 MCP 工具。 工具名格式:``skill_{skill.name}``。 真正的技能执行通过 ``executor`` 回调完成;未配置时 execute 返回错误信息。 """ def __init__( self, skill: Skill, executor: SkillExecutor | None = None, ) -> None: super().__init__( name=f"skill_{skill.name}", description=skill.config.description or f"Skill: {skill.name}", input_schema={ "type": "object", "properties": {"input": {"type": "string", "description": "技能输入"}}, "required": ["input"], }, tags=["skill", skill.name], ) self._skill = skill self._executor = executor async def execute(self, **kwargs: Any) -> dict[str, Any]: """调用 executor 执行技能;未配置或异常时返回错误 dict。""" input_text = kwargs.get("input", "") if self._executor is None: return {"error": f"skill '{self._skill.name}' executor not configured"} try: return await self._executor(self._skill.name, input_text) except Exception as e: return {"error": f"skill execution failed: {e}"} class TeamMCPAdapter(Tool): """将专家团队封装为 MCP 工具。 工具名格式:``team_{team_name}``。 """ def __init__( self, team_name: str, executor: SkillExecutor | None = None, description: str | None = None, ) -> None: super().__init__( name=f"team_{team_name}", description=description or f"Expert team: {team_name}", input_schema={ "type": "object", "properties": {"input": {"type": "string", "description": "团队任务输入"}}, "required": ["input"], }, tags=["team", team_name], ) self._team_name = team_name self._executor = executor async def execute(self, **kwargs: Any) -> dict[str, Any]: """调用 executor 执行团队任务;未配置或异常时返回错误 dict。""" input_text = kwargs.get("input", "") if self._executor is None: return {"error": f"team '{self._team_name}' executor not configured"} try: return await self._executor(self._team_name, input_text) except Exception as e: return {"error": f"team execution failed: {e}"} def check_dangerous_publish(skill: Skill, allow_dangerous: bool) -> None: """检查 Skill 绑定的工具是否包含危险工具。 若包含危险工具且未显式 ``allow_dangerous=True``,抛 ``ValueError``。 """ dangerous = [t.name for t in skill.tools if t.name in _DANGEROUS_TOOL_NAMES] if dangerous and not allow_dangerous: raise ValueError( f"Skill '{skill.name}' contains dangerous tools {dangerous}; " "publish requires allow_dangerous=True" )