From 13c516a54f0f4ce782fbeb1b266b565eb4cc8af3 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Thu, 25 Jun 2026 21:10:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20U14=20=E2=80=94=20Skill/Team=20MCP?= =?UTF-8?q?=20publish=20with=20admin=20auth=20+=20dangerous-tool=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agentkit/mcp/publisher.py | 150 ++++++ src/agentkit/mcp/server.py | 105 +++-- src/agentkit/server/app.py | 15 +- src/agentkit/server/routes/__init__.py | 2 + src/agentkit/server/routes/mcp_publish.py | 198 ++++++++ tests/unit/mcp/test_publisher.py | 526 ++++++++++++++++++++++ 6 files changed, 962 insertions(+), 34 deletions(-) create mode 100644 src/agentkit/mcp/publisher.py create mode 100644 src/agentkit/server/routes/mcp_publish.py create mode 100644 tests/unit/mcp/test_publisher.py diff --git a/src/agentkit/mcp/publisher.py b/src/agentkit/mcp/publisher.py new file mode 100644 index 0000000..cc0ff97 --- /dev/null +++ b/src/agentkit/mcp/publisher.py @@ -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" + ) diff --git a/src/agentkit/mcp/server.py b/src/agentkit/mcp/server.py index 1871cff..5907d29 100644 --- a/src/agentkit/mcp/server.py +++ b/src/agentkit/mcp/server.py @@ -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() diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index 39ef6ed..ac33806 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -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") diff --git a/src/agentkit/server/routes/__init__.py b/src/agentkit/server/routes/__init__.py index d0786e7..a9b2a0f 100644 --- a/src/agentkit/server/routes/__init__.py +++ b/src/agentkit/server/routes/__init__.py @@ -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", ] diff --git a/src/agentkit/server/routes/mcp_publish.py b/src/agentkit/server/routes/mcp_publish.py new file mode 100644 index 0000000..25397c0 --- /dev/null +++ b/src/agentkit/server/routes/mcp_publish.py @@ -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 + ] + } diff --git a/tests/unit/mcp/test_publisher.py b/tests/unit/mcp/test_publisher.py new file mode 100644 index 0000000..611923c --- /dev/null +++ b/tests/unit/mcp/test_publisher.py @@ -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)