151 lines
5.5 KiB
Python
151 lines
5.5 KiB
Python
"""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"
|
||
)
|