feat(mcp): U14 — Skill/Team MCP publish with admin auth + dangerous-tool opt-in

This commit is contained in:
chiguyong 2026-06-25 21:10:06 +08:00
parent 16c33be295
commit 13c516a54f
6 changed files with 962 additions and 34 deletions

View File

@ -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"
)

View File

@ -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()

View File

@ -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")

View File

@ -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",
]

View File

@ -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"])
# 发布端点要求 adminSYSTEM_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
]
}

View File

@ -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 JWTHS256"""
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 appMCP 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/listmember包含 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)