199 lines
6.1 KiB
Python
199 lines
6.1 KiB
Python
"""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 (KeyError, ValueError, AttributeError):
|
||
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
|
||
]
|
||
}
|