feat(mcp): U14 — Skill/Team MCP publish with admin auth + dangerous-tool opt-in
This commit is contained in:
parent
16c33be295
commit
13c516a54f
|
|
@ -0,0 +1,150 @@
|
|||
"""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"
|
||||
)
|
||||
|
|
@ -17,6 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
from agentkit.tools.base import Tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -25,7 +26,10 @@ logger = logging.getLogger(__name__)
|
|||
_mcp_member_auth = require_permission(Permission.CHAT)
|
||||
|
||||
|
||||
def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
||||
def create_mcp_router(
|
||||
tool_registry: Any = None,
|
||||
published_tools_getter: Any = None,
|
||||
) -> APIRouter:
|
||||
"""构造 MCP 路由,挂载到主 app 的 ``/api/v1/mcp/`` 前缀下。
|
||||
|
||||
所有端点要求至少 member 权限(Permission.CHAT)。认证由主 app
|
||||
|
|
@ -34,6 +38,9 @@ def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
|||
Args:
|
||||
tool_registry: ToolRegistry 实例。若为 None,所有工具相关
|
||||
端点返回空列表或错误。
|
||||
published_tools_getter: U14 可选回调,返回通过发布端点注册的
|
||||
``list[Tool]``。这些工具会与 ``tool_registry`` 中的工具合并
|
||||
后一起对外暴露。向后兼容:默认 None 时行为与 U13 一致。
|
||||
|
||||
Returns:
|
||||
APIRouter,包含以下路由:
|
||||
|
|
@ -44,12 +51,35 @@ def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
|||
"""
|
||||
router = APIRouter(tags=["mcp"])
|
||||
|
||||
def _all_tools() -> list[Tool]:
|
||||
"""合并 ToolRegistry 与已发布工具。"""
|
||||
tools: list[Tool] = []
|
||||
if tool_registry is not None:
|
||||
tools.extend(tool_registry.list_tools())
|
||||
if published_tools_getter is not None:
|
||||
try:
|
||||
tools.extend(published_tools_getter())
|
||||
except Exception:
|
||||
logger.exception("published_tools_getter failed")
|
||||
return tools
|
||||
|
||||
def _find_tool(name: str) -> Tool | None:
|
||||
"""按名查找工具(先 registry 后已发布)。"""
|
||||
if tool_registry is not None:
|
||||
try:
|
||||
return tool_registry.get(name)
|
||||
except Exception:
|
||||
pass
|
||||
if published_tools_getter is not None:
|
||||
for t in published_tools_getter():
|
||||
if t.name == name:
|
||||
return t
|
||||
return None
|
||||
|
||||
@router.get("/tools/list")
|
||||
async def list_tools(_user: dict = Depends(_mcp_member_auth)) -> dict[str, Any]:
|
||||
"""列出所有可用的 MCP 工具。"""
|
||||
if tool_registry is None:
|
||||
return {"tools": []}
|
||||
tools = tool_registry.list_tools()
|
||||
tools = _all_tools()
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
|
|
@ -70,15 +100,14 @@ def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
|||
tool_name = request.get("name")
|
||||
arguments = request.get("arguments", {})
|
||||
|
||||
if not tool_name or tool_registry is None:
|
||||
if not tool_name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Tool not specified or registry not configured",
|
||||
)
|
||||
|
||||
try:
|
||||
tool = tool_registry.get(tool_name)
|
||||
except Exception:
|
||||
tool = _find_tool(tool_name)
|
||||
if tool is None:
|
||||
raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found")
|
||||
|
||||
try:
|
||||
|
|
@ -122,36 +151,42 @@ def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
|||
"serverInfo": {"name": "agentkit-mcp-server", "version": "2.0.0"},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
if tool_registry is None:
|
||||
result = {"tools": []}
|
||||
else:
|
||||
tools = tool_registry.list_tools()
|
||||
result = {
|
||||
"tools": [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"inputSchema": t.input_schema or {},
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
}
|
||||
tools = _all_tools()
|
||||
result = {
|
||||
"tools": [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"inputSchema": t.input_schema or {},
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
}
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
if not tool_name or tool_registry is None:
|
||||
if not tool_name:
|
||||
result = {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": "Tool not found"}],
|
||||
}
|
||||
else:
|
||||
try:
|
||||
tool = tool_registry.get(tool_name)
|
||||
tool_result = await tool.safe_execute(**arguments)
|
||||
result = {"content": [{"type": "text", "text": str(tool_result)}]}
|
||||
except Exception as e:
|
||||
result = {"isError": True, "content": [{"type": "text", "text": str(e)}]}
|
||||
tool = _find_tool(tool_name)
|
||||
if tool is None:
|
||||
result = {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": "Tool not found"}],
|
||||
}
|
||||
else:
|
||||
try:
|
||||
tool_result = await tool.safe_execute(**arguments)
|
||||
result = {"content": [{"type": "text", "text": str(tool_result)}]}
|
||||
except Exception as e:
|
||||
result = {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": str(e)}],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
|
|
@ -245,7 +280,11 @@ class MCPServer:
|
|||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None}
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
"id": None,
|
||||
}
|
||||
|
||||
method = body.get("method", "")
|
||||
params = body.get("params", {})
|
||||
|
|
@ -277,7 +316,10 @@ class MCPServer:
|
|||
arguments = params.get("arguments", {})
|
||||
|
||||
if not tool_name or self._tool_registry is None:
|
||||
result = {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}
|
||||
result = {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": "Tool not found"}],
|
||||
}
|
||||
else:
|
||||
try:
|
||||
tool = self._tool_registry.get(tool_name)
|
||||
|
|
@ -303,6 +345,7 @@ class MCPServer:
|
|||
|
||||
try:
|
||||
import uvicorn
|
||||
|
||||
config = uvicorn.Config(self._app, host=self._host, port=self._port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
await server.serve()
|
||||
|
|
|
|||
|
|
@ -1085,12 +1085,21 @@ def create_app(
|
|||
app.include_router(bitable_routes.router, prefix="/api/v1")
|
||||
app.include_router(channels_routes.router, prefix="/api/v1")
|
||||
|
||||
# U13: 将 MCP Server 合并至主 app,挂载在 /api/v1/mcp/ 前缀下,
|
||||
# 复用主 app 的 JWT + API Key 认证中间件。
|
||||
# U13 + U14: 将 MCP Server 合并至主 app,挂载在 /api/v1/mcp/ 前缀下,
|
||||
# 复用主 app 的 JWT + API Key 认证中间件。U14 额外接入 PublisherRegistry,
|
||||
# 使通过发布端点注册的 Skill/团队工具也能在 MCP /tools/list 中可见。
|
||||
from agentkit.mcp.publisher import PublisherRegistry
|
||||
from agentkit.mcp.server import create_mcp_router
|
||||
from agentkit.server.routes import mcp_publish as mcp_publish_routes
|
||||
|
||||
mcp_router = create_mcp_router(tool_registry=getattr(app.state, "tool_registry", None))
|
||||
app.state.mcp_publisher_registry = PublisherRegistry()
|
||||
|
||||
mcp_router = create_mcp_router(
|
||||
tool_registry=getattr(app.state, "tool_registry", None),
|
||||
published_tools_getter=lambda: app.state.mcp_publisher_registry.list_published(),
|
||||
)
|
||||
app.include_router(mcp_router, prefix="/api/v1/mcp")
|
||||
app.include_router(mcp_publish_routes.router, prefix="/api/v1")
|
||||
|
||||
# Serve GUI when in GUI mode
|
||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from agentkit.server.routes import (
|
|||
terminal,
|
||||
experts,
|
||||
channels,
|
||||
mcp_publish,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -38,4 +39,5 @@ __all__ = [
|
|||
"terminal",
|
||||
"experts",
|
||||
"channels",
|
||||
"mcp_publish",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
"""MCP 发布路由 — 将 Skill / 专家团队发布为 MCP 工具
|
||||
|
||||
U14: 管理员通过这些端点把已注册的 Skill 或专家团队封装成 MCP 工具,
|
||||
注册到 ``app.state.mcp_publisher_registry`` 后即可通过 MCP 端点对外暴露。
|
||||
|
||||
所有发布/取消发布/列表端点均要求 ``SYSTEM_CONFIG`` 权限(admin)。
|
||||
已发布的工具本身通过 ``/api/v1/mcp/tools/list`` 等 member 级端点调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from agentkit.mcp.publisher import (
|
||||
PublisherRegistry,
|
||||
SkillMCPAdapter,
|
||||
TeamMCPAdapter,
|
||||
check_dangerous_publish,
|
||||
)
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["mcp-publish"])
|
||||
|
||||
# 发布端点要求 admin(SYSTEM_CONFIG)权限——防止普通用户把高危能力暴露给外部系统。
|
||||
_admin_auth = require_permission(Permission.SYSTEM_CONFIG)
|
||||
|
||||
|
||||
class SkillPublishRequest(BaseModel):
|
||||
"""Skill 发布请求体。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
allow_dangerous: bool = False
|
||||
description_override: str | None = None
|
||||
|
||||
|
||||
class TeamPublishRequest(BaseModel):
|
||||
"""专家团队发布请求体。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
description: str | None = None
|
||||
allow_dangerous: bool = False
|
||||
|
||||
|
||||
def _get_publisher_registry(request: Request) -> PublisherRegistry:
|
||||
"""获取 app.state 上的 PublisherRegistry;不存在则 500。"""
|
||||
registry = getattr(request.app.state, "mcp_publisher_registry", None)
|
||||
if registry is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="mcp_publisher_registry not configured on app.state",
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _build_skill_executor(app_state: Any):
|
||||
"""构造 Skill 执行器闭包。
|
||||
|
||||
lazy MVP: 仅当 ``app.state.agent_pool`` 存在且实现了 ``run_skill`` 时调用;
|
||||
否则返回错误 dict(不阻塞发布,调用时才报错)。
|
||||
ponytail: 完整的 agent 执行接线推迟到后续迭代。
|
||||
"""
|
||||
|
||||
async def _executor(skill_name: str, input_text: str) -> dict[str, Any]:
|
||||
pool = getattr(app_state, "agent_pool", None)
|
||||
if pool is None:
|
||||
return {"error": "agent_pool not available"}
|
||||
run_skill = getattr(pool, "run_skill", None)
|
||||
if run_skill is None:
|
||||
return {"error": "agent_pool.run_skill not implemented"}
|
||||
return await run_skill(skill_name, input_text)
|
||||
|
||||
return _executor
|
||||
|
||||
|
||||
def _build_team_executor(app_state: Any):
|
||||
"""构造专家团队执行器闭包。"""
|
||||
|
||||
async def _executor(team_name: str, input_text: str) -> dict[str, Any]:
|
||||
pool = getattr(app_state, "agent_pool", None)
|
||||
if pool is None:
|
||||
return {"error": "agent_pool not available"}
|
||||
run_team = getattr(pool, "run_team", None)
|
||||
if run_team is None:
|
||||
return {"error": "agent_pool.run_team not implemented"}
|
||||
return await run_team(team_name, input_text)
|
||||
|
||||
return _executor
|
||||
|
||||
|
||||
@router.post("/mcp/publish/skill/{skill_name}")
|
||||
async def publish_skill(
|
||||
skill_name: str,
|
||||
body: SkillPublishRequest,
|
||||
request: Request,
|
||||
_user: dict = Depends(_admin_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""发布一个 Skill 为 MCP 工具。
|
||||
|
||||
- Skill 不存在 → 404
|
||||
- Skill 含危险工具且未显式 allow_dangerous → 403
|
||||
- 同名工具已发布 → 409
|
||||
"""
|
||||
skill_registry = getattr(request.app.state, "skill_registry", None)
|
||||
if skill_registry is None:
|
||||
raise HTTPException(status_code=500, detail="skill_registry not configured")
|
||||
|
||||
try:
|
||||
skill = skill_registry.get(skill_name)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
try:
|
||||
check_dangerous_publish(skill, body.allow_dangerous)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
executor = _build_skill_executor(request.app.state)
|
||||
description = body.description_override or None
|
||||
adapter = SkillMCPAdapter(skill, executor=executor)
|
||||
if description is not None:
|
||||
adapter.description = description
|
||||
|
||||
registry = _get_publisher_registry(request)
|
||||
try:
|
||||
registry.register(adapter)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"published": adapter.name, "type": "skill"}
|
||||
|
||||
|
||||
@router.post("/mcp/publish/team/{team_name}")
|
||||
async def publish_team(
|
||||
team_name: str,
|
||||
body: TeamPublishRequest,
|
||||
request: Request,
|
||||
_user: dict = Depends(_admin_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""发布一个专家团队为 MCP 工具。
|
||||
|
||||
- 同名工具已发布 → 409
|
||||
"""
|
||||
executor = _build_team_executor(request.app.state)
|
||||
adapter = TeamMCPAdapter(
|
||||
team_name,
|
||||
executor=executor,
|
||||
description=body.description,
|
||||
)
|
||||
|
||||
registry = _get_publisher_registry(request)
|
||||
try:
|
||||
registry.register(adapter)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"published": adapter.name, "type": "team"}
|
||||
|
||||
|
||||
@router.delete("/mcp/publish/{name}")
|
||||
async def unpublish(
|
||||
name: str,
|
||||
request: Request,
|
||||
_user: dict = Depends(_admin_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""取消发布一个 MCP 工具。不存在 → 404。"""
|
||||
registry = _get_publisher_registry(request)
|
||||
if not registry.unregister(name):
|
||||
raise HTTPException(status_code=404, detail=f"Published tool '{name}' not found")
|
||||
return {"unpublished": name}
|
||||
|
||||
|
||||
@router.get("/mcp/publish")
|
||||
async def list_published(
|
||||
request: Request,
|
||||
_user: dict = Depends(_admin_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""列出所有已发布的 MCP 工具。"""
|
||||
registry = _get_publisher_registry(request)
|
||||
tools = registry.list_published()
|
||||
return {
|
||||
"published": [
|
||||
{
|
||||
"name": t.name,
|
||||
"type": t.tags[0] if t.tags else "unknown",
|
||||
"description": t.description,
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
"""U14 — Skill/专家团队 MCP 发布的单元测试。
|
||||
|
||||
覆盖场景:
|
||||
- 管理员发布 Skill 成功 / 非管理员拒绝 / 无认证拒绝 / Skill 不存在
|
||||
- 危险 Skill 默认拒绝 / 显式 opt-in 放行 / 重复发布 409
|
||||
- 已发布 Skill 在 /tools/list 可见 / 外部系统调用已发布 Skill
|
||||
- DELETE 取消发布 / DELETE 不存在 404 / GET 列出已发布
|
||||
- 团队发布 / 团队在 /tools/list 可见
|
||||
- PublisherRegistry 单元 CRUD
|
||||
- SkillMCPAdapter.execute 无 executor / executor 异常
|
||||
- TeamMCPAdapter 名称格式
|
||||
- check_dangerous_publish 单元测试
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agentkit.mcp.publisher import (
|
||||
PublisherRegistry,
|
||||
SkillMCPAdapter,
|
||||
TeamMCPAdapter,
|
||||
_DANGEROUS_TOOL_NAMES,
|
||||
check_dangerous_publish,
|
||||
)
|
||||
from agentkit.mcp.server import create_mcp_router
|
||||
from agentkit.server.auth.middleware import AuthMiddleware
|
||||
from agentkit.server.routes import mcp_publish as mcp_publish_routes
|
||||
|
||||
# 测试用的固定凭据 — 仅限单元测试,不可用于生产。
|
||||
JWT_SECRET = "u14-test-jwt-secret-xxxxxxxxxxxxx"
|
||||
API_KEY = "u14-test-api-key-zzz"
|
||||
|
||||
|
||||
# ── 测试辅助函数 ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_jwt(role: str = "member", user_id: str = "u1", username: str = "alice") -> str:
|
||||
"""签发一个测试用 access JWT(HS256)。"""
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"type": "access",
|
||||
"iat": 1700000000,
|
||||
"exp": 9999999999,
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
return token.decode("utf-8") if isinstance(token, bytes) else token
|
||||
|
||||
|
||||
def _make_mock_skill(
|
||||
name: str = "my_skill",
|
||||
description: str = "Test skill",
|
||||
tool_names: list[str] | None = None,
|
||||
) -> MagicMock:
|
||||
"""构造一个 mock Skill,模拟 Skill 接口(name/config/tools)。"""
|
||||
skill = MagicMock()
|
||||
skill.name = name
|
||||
skill.config.name = name
|
||||
skill.config.description = description
|
||||
skill_tools = []
|
||||
if tool_names:
|
||||
for tn in tool_names:
|
||||
t = MagicMock()
|
||||
t.name = tn
|
||||
skill_tools.append(t)
|
||||
skill.tools = skill_tools
|
||||
return skill
|
||||
|
||||
|
||||
def _make_mock_skill_registry(skills: list) -> MagicMock:
|
||||
"""构造一个 mock SkillRegistry,支持 get(name) 抛 KeyError 表示未找到。"""
|
||||
registry = MagicMock()
|
||||
|
||||
def _get(name: str):
|
||||
for s in skills:
|
||||
if s.name == name:
|
||||
return s
|
||||
raise KeyError(name)
|
||||
|
||||
registry.get = _get
|
||||
registry.list_skills.return_value = skills
|
||||
return registry
|
||||
|
||||
|
||||
def _make_app(
|
||||
skill_registry: Any = None,
|
||||
agent_pool: Any = None,
|
||||
) -> FastAPI:
|
||||
"""构造测试用 FastAPI app:MCP router + 发布路由 + AuthMiddleware。"""
|
||||
app = FastAPI()
|
||||
app.state.tool_registry = None
|
||||
app.state.skill_registry = skill_registry
|
||||
app.state.agent_pool = agent_pool
|
||||
app.state.mcp_publisher_registry = PublisherRegistry()
|
||||
|
||||
app.add_middleware(AuthMiddleware, jwt_secret=JWT_SECRET, api_key=API_KEY)
|
||||
|
||||
mcp_router = create_mcp_router(
|
||||
tool_registry=None,
|
||||
published_tools_getter=lambda: app.state.mcp_publisher_registry.list_published(),
|
||||
)
|
||||
app.include_router(mcp_router, prefix="/api/v1/mcp")
|
||||
app.include_router(mcp_publish_routes.router, prefix="/api/v1")
|
||||
return app
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {_make_jwt(role='admin')}"}
|
||||
|
||||
|
||||
def _member_headers() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {_make_jwt(role='member')}"}
|
||||
|
||||
|
||||
# ── 1-3: 认证与权限 ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestPublishAuth:
|
||||
"""发布端点的认证与权限校验。"""
|
||||
|
||||
async def test_admin_publish_skill_success(self):
|
||||
"""场景 1: 管理员发布 Skill → 200 + published 名称。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"published": "skill_my_skill", "type": "skill"}
|
||||
|
||||
async def test_member_publish_skill_rejected_403(self):
|
||||
"""场景 2: member 角色发布 → 403(需 SYSTEM_CONFIG 权限)。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_member_headers(),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_no_auth_publish_rejected_401(self):
|
||||
"""场景 3: 无认证发布 → 401。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── 4-7: Skill 发布业务逻辑 ──────────────────────────────
|
||||
|
||||
|
||||
class TestPublishSkillLogic:
|
||||
"""Skill 发布的业务逻辑:404 / 危险工具 / 重复发布。"""
|
||||
|
||||
async def test_skill_not_found_404(self):
|
||||
"""场景 4: 发布不存在的 Skill → 404。"""
|
||||
registry = _make_mock_skill_registry([])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/nonexistent",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_dangerous_skill_default_rejected_403(self):
|
||||
"""场景 5: Skill 含 terminal 工具,默认(allow_dangerous=false)→ 403。"""
|
||||
skill = _make_mock_skill(name="dangerous_skill", tool_names=["terminal"])
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/dangerous_skill",
|
||||
json={"allow_dangerous": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_dangerous_skill_explicit_optin_success(self):
|
||||
"""场景 6: 同样危险 Skill,显式 allow_dangerous=true → 200。"""
|
||||
skill = _make_mock_skill(name="dangerous_skill", tool_names=["terminal"])
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/skill/dangerous_skill",
|
||||
json={"allow_dangerous": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"published": "skill_dangerous_skill", "type": "skill"}
|
||||
|
||||
async def test_duplicate_publish_returns_409(self):
|
||||
"""场景 7: 重复发布同一 Skill → 第二次 409。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
# ── 8-9: 已发布工具的可见性与调用 ─────────────────────────
|
||||
|
||||
|
||||
class TestPublishedToolVisibility:
|
||||
"""已发布工具在 MCP 端点的可见性与调用。"""
|
||||
|
||||
async def test_published_skill_visible_in_tools_list(self):
|
||||
"""场景 8: 发布后 GET /tools/list(member)包含 skill_my_skill。"""
|
||||
skill = _make_mock_skill(name="my_skill", description="desc")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/v1/mcp/tools/list",
|
||||
headers=_member_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
names = {t["name"] for t in resp.json()["tools"]}
|
||||
assert "skill_my_skill" in names
|
||||
|
||||
async def test_call_published_skill(self):
|
||||
"""场景 9: 外部系统调用已发布 Skill → 200 + executor 返回结果。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
# mock agent_pool.run_skill 返回固定结果
|
||||
pool = MagicMock()
|
||||
pool.run_skill = AsyncMock(return_value={"result": "processed: hello"})
|
||||
app = _make_app(skill_registry=registry, agent_pool=pool)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/tools/call",
|
||||
json={"name": "skill_my_skill", "arguments": {"input": "hello"}},
|
||||
headers=_member_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "processed: hello" in body["content"][0]["text"]
|
||||
pool.run_skill.assert_awaited_once_with("my_skill", "hello")
|
||||
|
||||
|
||||
# ── 10-12: 取消发布与列表 ────────────────────────────────
|
||||
|
||||
|
||||
class TestUnpublishAndList:
|
||||
"""DELETE 取消发布 / GET 列出已发布。"""
|
||||
|
||||
async def test_unpublish_success(self):
|
||||
"""场景 10: 发布后 DELETE → 200;随后 /tools/list 不再包含。"""
|
||||
skill = _make_mock_skill(name="my_skill")
|
||||
registry = _make_mock_skill_registry([skill])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/skill/my_skill",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
resp = await client.delete(
|
||||
"/api/v1/mcp/publish/skill_my_skill",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"unpublished": "skill_my_skill"}
|
||||
# 验证 /tools/list 不再包含
|
||||
list_resp = await client.get(
|
||||
"/api/v1/mcp/tools/list",
|
||||
headers=_member_headers(),
|
||||
)
|
||||
names = {t["name"] for t in list_resp.json()["tools"]}
|
||||
assert "skill_my_skill" not in names
|
||||
|
||||
async def test_unpublish_unknown_404(self):
|
||||
"""场景 11: DELETE 不存在的名称 → 404。"""
|
||||
app = _make_app(skill_registry=_make_mock_skill_registry([]))
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete(
|
||||
"/api/v1/mcp/publish/skill_unknown",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_list_published(self):
|
||||
"""场景 12: 发布 2 个 Skill 后 GET /mcp/publish → 200 + 2 条目。"""
|
||||
skill_a = _make_mock_skill(name="skill_a")
|
||||
skill_b = _make_mock_skill(name="skill_b")
|
||||
registry = _make_mock_skill_registry([skill_a, skill_b])
|
||||
app = _make_app(skill_registry=registry)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/skill/skill_a",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/skill/skill_b",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/v1/mcp/publish",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["published"]}
|
||||
assert names == {"skill_skill_a", "skill_skill_b"}
|
||||
|
||||
|
||||
# ── 13-14: 团队发布 ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestPublishTeam:
|
||||
"""专家团队发布为 MCP 工具。"""
|
||||
|
||||
async def test_publish_team_success(self):
|
||||
"""场景 13: 管理员发布团队 → 200 + team_dev_team。"""
|
||||
app = _make_app(skill_registry=_make_mock_skill_registry([]))
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/mcp/publish/team/dev_team",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"published": "team_dev_team", "type": "team"}
|
||||
|
||||
async def test_team_visible_in_tools_list(self):
|
||||
"""场景 14: 团队发布后 GET /tools/list 包含 team_dev_team。"""
|
||||
app = _make_app(skill_registry=_make_mock_skill_registry([]))
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/mcp/publish/team/dev_team",
|
||||
json={},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/v1/mcp/tools/list",
|
||||
headers=_member_headers(),
|
||||
)
|
||||
names = {t["name"] for t in resp.json()["tools"]}
|
||||
assert "team_dev_team" in names
|
||||
|
||||
|
||||
# ── 15-19: PublisherRegistry 与 Adapter 单元测试 ─────────
|
||||
|
||||
|
||||
class TestPublisherRegistry:
|
||||
"""PublisherRegistry 基础 CRUD。"""
|
||||
|
||||
def test_register_unregister_list_get(self):
|
||||
"""场景 15: register / unregister / list_published / get 全流程。"""
|
||||
reg = PublisherRegistry()
|
||||
tool = _make_mock_skill_adapter("a")
|
||||
# 适配器名称为 skill_a(带 skill_ 前缀)
|
||||
assert tool.name == "skill_a"
|
||||
assert reg.list_published() == []
|
||||
assert reg.get("skill_a") is None
|
||||
|
||||
reg.register(tool)
|
||||
assert reg.get("skill_a") is tool
|
||||
assert [t.name for t in reg.list_published()] == ["skill_a"]
|
||||
|
||||
assert reg.unregister("skill_a") is True
|
||||
assert reg.get("skill_a") is None
|
||||
assert reg.list_published() == []
|
||||
# 重复注销返回 False
|
||||
assert reg.unregister("skill_a") is False
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
"""重复 register 同名工具应抛 ValueError。"""
|
||||
reg = PublisherRegistry()
|
||||
reg.register(_make_mock_skill_adapter("a"))
|
||||
with pytest.raises(ValueError):
|
||||
reg.register(_make_mock_skill_adapter("a"))
|
||||
|
||||
|
||||
class TestSkillMCPAdapter:
|
||||
"""SkillMCPAdapter.execute 行为。"""
|
||||
|
||||
async def test_execute_without_executor_returns_error(self):
|
||||
"""场景 16: executor=None → execute 返回错误 dict。"""
|
||||
skill = _make_mock_skill(name="s1")
|
||||
adapter = SkillMCPAdapter(skill, executor=None)
|
||||
result = await adapter.execute(input="hi")
|
||||
assert "error" in result
|
||||
assert "executor not configured" in result["error"]
|
||||
|
||||
async def test_execute_with_raising_executor_returns_error(self):
|
||||
"""场景 17: executor 抛异常 → execute 捕获并返回错误 dict。"""
|
||||
skill = _make_mock_skill(name="s1")
|
||||
|
||||
async def _boom(_name: str, _input: str) -> dict[str, Any]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
adapter = SkillMCPAdapter(skill, executor=_boom)
|
||||
result = await adapter.execute(input="hi")
|
||||
assert "error" in result
|
||||
assert "skill execution failed" in result["error"]
|
||||
assert "boom" in result["error"]
|
||||
|
||||
async def test_execute_with_executor_returns_result(self):
|
||||
"""executor 正常返回 → execute 透传结果。"""
|
||||
skill = _make_mock_skill(name="s1")
|
||||
|
||||
async def _ok(_name: str, _input: str) -> dict[str, Any]:
|
||||
return {"output": f"done:{_input}"}
|
||||
|
||||
adapter = SkillMCPAdapter(skill, executor=_ok)
|
||||
result = await adapter.execute(input="hi")
|
||||
assert result == {"output": "done:hi"}
|
||||
|
||||
def test_skill_adapter_name_format(self):
|
||||
"""适配器名称格式为 skill_{name}。"""
|
||||
skill = _make_mock_skill(name="my_skill", description="d")
|
||||
adapter = SkillMCPAdapter(skill)
|
||||
assert adapter.name == "skill_my_skill"
|
||||
assert adapter.description == "d"
|
||||
assert adapter.input_schema["required"] == ["input"]
|
||||
|
||||
|
||||
class TestTeamMCPAdapter:
|
||||
"""TeamMCPAdapter 名称与执行。"""
|
||||
|
||||
def test_team_adapter_name_format(self):
|
||||
"""场景 18: 团队适配器名称格式为 team_{name}。"""
|
||||
adapter = TeamMCPAdapter("dev_team")
|
||||
assert adapter.name == "team_dev_team"
|
||||
assert "team" in adapter.tags
|
||||
|
||||
async def test_team_execute_without_executor(self):
|
||||
"""团队 executor=None → 返回错误 dict。"""
|
||||
adapter = TeamMCPAdapter("dev_team", executor=None)
|
||||
result = await adapter.execute(input="x")
|
||||
assert "error" in result
|
||||
assert "executor not configured" in result["error"]
|
||||
|
||||
|
||||
class TestCheckDangerousPublish:
|
||||
"""check_dangerous_publish 校验逻辑。"""
|
||||
|
||||
def test_safe_skill_no_raise(self):
|
||||
"""场景 19: 无危险工具的 Skill 不抛异常。"""
|
||||
skill = _make_mock_skill(name="s", tool_names=["web_search", "rag"])
|
||||
# 不应抛异常
|
||||
check_dangerous_publish(skill, allow_dangerous=False)
|
||||
|
||||
def test_dangerous_skill_without_optin_raises(self):
|
||||
"""含 shell 工具且 allow_dangerous=False → 抛 ValueError。"""
|
||||
skill = _make_mock_skill(name="s", tool_names=["shell"])
|
||||
with pytest.raises(ValueError):
|
||||
check_dangerous_publish(skill, allow_dangerous=False)
|
||||
|
||||
def test_dangerous_skill_with_optin_no_raise(self):
|
||||
"""含 shell 工具且 allow_dangerous=True → 不抛异常。"""
|
||||
skill = _make_mock_skill(name="s", tool_names=["shell", "file_write"])
|
||||
check_dangerous_publish(skill, allow_dangerous=True)
|
||||
|
||||
def test_dangerous_tool_names_contains_expected(self):
|
||||
"""_DANGEROUS_TOOL_NAMES 包含预期高危工具名。"""
|
||||
for name in {"terminal", "shell", "file_write", "file_read", "file_delete"}:
|
||||
assert name in _DANGEROUS_TOOL_NAMES
|
||||
|
||||
|
||||
# ── 辅助:构造 SkillMCPAdapter 用于 registry 测试 ─────────
|
||||
|
||||
|
||||
def _make_mock_skill_adapter(name: str) -> SkillMCPAdapter:
|
||||
"""构造一个最小 SkillMCPAdapter 用于 registry 测试。"""
|
||||
skill = _make_mock_skill(name=name)
|
||||
return SkillMCPAdapter(skill, executor=None)
|
||||
Loading…
Reference in New Issue