feat(mcp): U13 — refactor MCPServer to route factory + mount at /api/v1/mcp with auth
This commit is contained in:
parent
8998f94c42
commit
16c33be295
|
|
@ -1,18 +1,176 @@
|
||||||
"""MCP Server - 将 Agent 能力暴露为 MCP 工具
|
"""MCP Server - 将 Agent 能力暴露为 MCP 工具
|
||||||
|
|
||||||
基于 FastAPI 实现,支持 Streamable HTTP 传输。
|
基于 FastAPI 实现,支持 Streamable HTTP 传输。
|
||||||
|
|
||||||
|
U13: 重构为路由工厂 ``create_mcp_router()``,可挂载到主 FastAPI app
|
||||||
|
的 ``/api/v1/mcp/`` 前缀下,复用主 app 的认证中间件(JWT + API Key)。
|
||||||
|
旧的独立 ``MCPServer`` 类保留作为向后兼容(独立 port 8080 部署),
|
||||||
|
但在 ``_create_app()`` 时发出 DeprecationWarning。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from agentkit.server.auth.dependencies import require_permission
|
||||||
|
from agentkit.server.auth.permissions import Permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MCP 端点要求至少 member 权限(CHAT)。API Key 与 JWT 均可。
|
||||||
|
# 认证由主 app 的 AuthMiddleware 处理,这里只做权限校验。
|
||||||
|
_mcp_member_auth = require_permission(Permission.CHAT)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mcp_router(tool_registry: Any = None) -> APIRouter:
|
||||||
|
"""构造 MCP 路由,挂载到主 app 的 ``/api/v1/mcp/`` 前缀下。
|
||||||
|
|
||||||
|
所有端点要求至少 member 权限(Permission.CHAT)。认证由主 app
|
||||||
|
的 AuthMiddleware 处理(JWT + API Key 双轨)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_registry: ToolRegistry 实例。若为 None,所有工具相关
|
||||||
|
端点返回空列表或错误。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
APIRouter,包含以下路由:
|
||||||
|
- GET /tools/list — 列出 MCP 工具
|
||||||
|
- POST /tools/call — 调用 MCP 工具
|
||||||
|
- GET /health — 健康检查
|
||||||
|
- POST / — JSON-RPC 2.0 端点(MCP 协议兼容)
|
||||||
|
"""
|
||||||
|
router = APIRouter(tags=["mcp"])
|
||||||
|
|
||||||
|
@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()
|
||||||
|
return {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"inputSchema": t.input_schema or {},
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/tools/call")
|
||||||
|
async def call_tool(
|
||||||
|
request: dict,
|
||||||
|
_user: dict = Depends(_mcp_member_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""调用指定的 MCP 工具。"""
|
||||||
|
tool_name = request.get("name")
|
||||||
|
arguments = request.get("arguments", {})
|
||||||
|
|
||||||
|
if not tool_name or tool_registry is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Tool not specified or registry not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tool = tool_registry.get(tool_name)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await tool.safe_execute(**arguments)
|
||||||
|
return {"content": [{"type": "text", "text": str(result)}]}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"MCP tool '{tool_name}' execution failed")
|
||||||
|
return {"isError": True, "content": [{"type": "text", "text": str(e)}]}
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health(_user: dict = Depends(_mcp_member_auth)) -> dict[str, str]:
|
||||||
|
"""MCP 健康检查。"""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def jsonrpc_endpoint(
|
||||||
|
request: Request,
|
||||||
|
_user: dict = Depends(_mcp_member_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""JSON-RPC 2.0 端点 — MCP 协议兼容。
|
||||||
|
|
||||||
|
支持 methods: initialize, tools/list, tools/call。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {"code": -32700, "message": "Parse error"},
|
||||||
|
"id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
method = body.get("method", "")
|
||||||
|
params = body.get("params", {})
|
||||||
|
req_id = body.get("id")
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
result = {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name", "")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
if not tool_name or tool_registry is None:
|
||||||
|
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)}]}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||||
|
"id": req_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"jsonrpc": "2.0", "result": result, "id": req_id}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compat: legacy MCPServer class
|
||||||
class MCPServer:
|
class MCPServer:
|
||||||
"""MCP Server - 暴露 Agent 能力为 MCP 工具
|
"""[DEPRECATED] 独立 MCP Server — 使用 create_mcp_router() 替代。
|
||||||
|
|
||||||
自动将 ToolRegistry 中注册的工具暴露为 MCP 工具端点。
|
保留用于向后兼容(独立 port 8080 部署,零认证 — 仅适合隔离网络)。
|
||||||
|
将在下一版本移除。新部署应通过 :func:`create_mcp_router` 挂载到主 app
|
||||||
|
的 ``/api/v1/mcp/`` 前缀下,复用主 app 的 JWT + API Key 认证。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tool_registry: Any = None, host: str = "0.0.0.0", port: int = 8080):
|
def __init__(self, tool_registry: Any = None, host: str = "0.0.0.0", port: int = 8080):
|
||||||
|
|
@ -22,10 +180,22 @@ class MCPServer:
|
||||||
self._app = None
|
self._app = None
|
||||||
|
|
||||||
def _create_app(self):
|
def _create_app(self):
|
||||||
"""创建 FastAPI 应用"""
|
"""创建 FastAPI 应用(无认证 — 仅向后兼容)。
|
||||||
|
|
||||||
|
发出 :class:`DeprecationWarning` 提示迁移到 ``create_mcp_router()``。
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"MCPServer 独立 app 无认证保护,建议迁移到 create_mcp_router() "
|
||||||
|
"并挂载到主 app 的 /api/v1/mcp/。详见 U13。",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi import Request
|
from fastapi import Request # noqa: F401 — 用于 jsonrpc_endpoint 的类型注解
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError("MCP Server requires fastapi: pip install fischer-agentkit[mcp]")
|
raise ImportError("MCP Server requires fastapi: pip install fischer-agentkit[mcp]")
|
||||||
|
|
||||||
|
|
@ -72,8 +242,6 @@ class MCPServer:
|
||||||
|
|
||||||
Handles requests from HTTPTransport which sends JSON-RPC format.
|
Handles requests from HTTPTransport which sends JSON-RPC format.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -1085,6 +1085,13 @@ def create_app(
|
||||||
app.include_router(bitable_routes.router, prefix="/api/v1")
|
app.include_router(bitable_routes.router, prefix="/api/v1")
|
||||||
app.include_router(channels_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 认证中间件。
|
||||||
|
from agentkit.mcp.server import create_mcp_router
|
||||||
|
|
||||||
|
mcp_router = create_mcp_router(tool_registry=getattr(app.state, "tool_registry", None))
|
||||||
|
app.include_router(mcp_router, prefix="/api/v1/mcp")
|
||||||
|
|
||||||
# Serve GUI when in GUI mode
|
# Serve GUI when in GUI mode
|
||||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||||
if gui_mode:
|
if gui_mode:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""U13 — MCP Server 认证 + 合并至主 app 的单元测试。
|
||||||
|
|
||||||
|
覆盖场景:
|
||||||
|
- 无认证 / 无效凭据 → 401
|
||||||
|
- 有效 API Key / 有效 JWT(member)→ 200 + 工具列表
|
||||||
|
- 有效 JWT 但无权限(guest)→ 403
|
||||||
|
- tools/call 成功 / 未知工具(404) / 执行错误
|
||||||
|
- JSON-RPC 2.0 端点(initialize / tools/list / tools/call / 未知方法 / 解析错误)
|
||||||
|
- MCPServer 类向后兼容(DeprecationWarning)
|
||||||
|
- create_mcp_router() 返回 APIRouter / 空 registry
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.routing import APIRouter
|
||||||
|
|
||||||
|
from agentkit.mcp.server import MCPServer, create_mcp_router
|
||||||
|
from agentkit.server.auth.middleware import AuthMiddleware
|
||||||
|
|
||||||
|
# 测试用的固定凭据 — 仅限单元测试,不可用于生产。
|
||||||
|
JWT_SECRET = "u13-test-jwt-secret-xxxxxxxxxxxxx"
|
||||||
|
API_KEY = "u13-test-api-key-yyy"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 测试辅助函数 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tool_registry: Any = None, *, dev_mode: bool = False) -> FastAPI:
|
||||||
|
"""构造测试用 FastAPI app:挂载 MCP router + AuthMiddleware。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_registry: ToolRegistry 实例(或 mock)。None 表示未配置。
|
||||||
|
dev_mode: True = 不添加 AuthMiddleware(开发模式,所有请求放行)。
|
||||||
|
"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.tool_registry = tool_registry
|
||||||
|
|
||||||
|
if not dev_mode:
|
||||||
|
app.add_middleware(AuthMiddleware, jwt_secret=JWT_SECRET, api_key=API_KEY)
|
||||||
|
|
||||||
|
mcp_router = create_mcp_router(tool_registry=tool_registry)
|
||||||
|
app.include_router(mcp_router, prefix="/api/v1/mcp")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_tool(
|
||||||
|
name: str = "echo",
|
||||||
|
description: str = "Echo tool",
|
||||||
|
input_schema: dict | None = None,
|
||||||
|
result: str = "echo: hi",
|
||||||
|
) -> MagicMock:
|
||||||
|
"""构造一个 mock 工具,模拟 Tool 接口。"""
|
||||||
|
tool = MagicMock()
|
||||||
|
tool.name = name
|
||||||
|
tool.description = description
|
||||||
|
tool.input_schema = input_schema or {"type": "object", "properties": {}}
|
||||||
|
tool.safe_execute = AsyncMock(return_value=result)
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_registry(tools: list) -> MagicMock:
|
||||||
|
"""构造一个 mock ToolRegistry,支持 list_tools() 和 get(name)。"""
|
||||||
|
registry = MagicMock()
|
||||||
|
registry.list_tools.return_value = tools
|
||||||
|
|
||||||
|
def _get(name: str):
|
||||||
|
for t in tools:
|
||||||
|
if t.name == name:
|
||||||
|
return t
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
registry.get = _get
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jwt(
|
||||||
|
role: str = "member",
|
||||||
|
user_id: str = "u1",
|
||||||
|
username: str = "alice",
|
||||||
|
) -> str:
|
||||||
|
"""签发一个测试用 access JWT(type=access,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_registry_with_two_tools() -> MagicMock:
|
||||||
|
"""构造含两个 mock 工具的 registry(echo + reverse)。"""
|
||||||
|
echo = _make_mock_tool(name="echo", description="Echo input", result="echo: hi")
|
||||||
|
reverse = _make_mock_tool(
|
||||||
|
name="reverse",
|
||||||
|
description="Reverse text",
|
||||||
|
result="dcba",
|
||||||
|
)
|
||||||
|
return _make_mock_registry([echo, reverse])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1-2: 无认证 / 无效凭据 ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoAuth:
|
||||||
|
"""无认证或无效凭据应返回 401。"""
|
||||||
|
|
||||||
|
async def test_no_auth_returns_401(self):
|
||||||
|
"""场景 1: 不带 Authorization 头 → 401。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get("/api/v1/mcp/tools/list")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_invalid_api_key_returns_401(self):
|
||||||
|
"""场景 2: 无效 X-API-Key → 401。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/tools/list",
|
||||||
|
headers={"X-API-Key": "invalid-key"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3-5: 有效凭据 + 权限校验 ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidAuth:
|
||||||
|
"""有效凭据应通过认证,权限校验决定 200/403。"""
|
||||||
|
|
||||||
|
async def test_valid_api_key_returns_tool_list(self):
|
||||||
|
"""场景 3: 有效 X-API-Key → 200 + 工具列表。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/tools/list",
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "tools" in body
|
||||||
|
names = {t["name"] for t in body["tools"]}
|
||||||
|
assert names == {"echo", "reverse"}
|
||||||
|
|
||||||
|
async def test_valid_jwt_member_returns_tool_list(self):
|
||||||
|
"""场景 4: 有效 JWT(member 角色)→ 200 + 工具列表。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
token = _make_jwt(role="member")
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/tools/list",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["tools"]) == 2
|
||||||
|
|
||||||
|
async def test_valid_jwt_guest_no_permission_returns_403(self):
|
||||||
|
"""场景 5: 有效 JWT 但 role=guest(无 CHAT 权限)→ 403。
|
||||||
|
|
||||||
|
guest 角色不在 ROLE_PERMISSIONS 中,故 has_permission 返回 False。
|
||||||
|
"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
token = _make_jwt(role="guest")
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/tools/list",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6-8: tools/call 端点 ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallTool:
|
||||||
|
"""POST /api/v1/mcp/tools/call 的成功 / 404 / 执行错误。"""
|
||||||
|
|
||||||
|
async def test_call_tool_success(self):
|
||||||
|
"""场景 6: 有效认证 + 有效工具 → 200 + 结果。"""
|
||||||
|
registry = _make_registry_with_two_tools()
|
||||||
|
app = _make_app(tool_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/tools/call",
|
||||||
|
json={"name": "echo", "arguments": {"text": "hi"}},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["content"][0]["type"] == "text"
|
||||||
|
assert "echo: hi" in body["content"][0]["text"]
|
||||||
|
|
||||||
|
async def test_call_tool_unknown_returns_404(self):
|
||||||
|
"""场景 7: 有效认证 + 未知工具 → 404。"""
|
||||||
|
registry = _make_registry_with_two_tools()
|
||||||
|
app = _make_app(tool_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/tools/call",
|
||||||
|
json={"name": "nonexistent", "arguments": {}},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_call_tool_execution_error_returns_isError(self):
|
||||||
|
"""场景 8: 有效认证 + 工具抛异常 → 200 + isError=True。"""
|
||||||
|
failing_tool = _make_mock_tool(name="boom", result="should-not-reach")
|
||||||
|
failing_tool.safe_execute = AsyncMock(side_effect=RuntimeError("boom!"))
|
||||||
|
registry = _make_mock_registry([failing_tool])
|
||||||
|
app = _make_app(tool_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/tools/call",
|
||||||
|
json={"name": "boom", "arguments": {}},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body.get("isError") is True
|
||||||
|
assert "boom!" in body["content"][0]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 9: health 端点 ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthEndpoint:
|
||||||
|
"""GET /api/v1/mcp/health — 有效认证 → 200。"""
|
||||||
|
|
||||||
|
async def test_health_with_valid_auth(self):
|
||||||
|
"""场景 9: 有效认证 → 200 + {"status": "ok"}。"""
|
||||||
|
app = _make_app(tool_registry=None)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/health",
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 10-14: JSON-RPC 2.0 端点 ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonRpcEndpoint:
|
||||||
|
"""POST /api/v1/mcp/ — MCP 协议兼容的 JSON-RPC 2.0 端点。"""
|
||||||
|
|
||||||
|
async def test_jsonrpc_initialize(self):
|
||||||
|
"""场景 10: initialize → 200 + protocolVersion。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp/",
|
||||||
|
json={"jsonrpc": "2.0", "method": "initialize", "id": 1},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["jsonrpc"] == "2.0"
|
||||||
|
assert body["id"] == 1
|
||||||
|
assert body["result"]["protocolVersion"] == "2024-11-05"
|
||||||
|
assert body["result"]["serverInfo"]["name"] == "agentkit-mcp-server"
|
||||||
|
|
||||||
|
async def test_jsonrpc_tools_list(self):
|
||||||
|
"""场景 11: tools/list via JSON-RPC → 200 + tools 数组。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp/",
|
||||||
|
json={"jsonrpc": "2.0", "method": "tools/list", "id": 2},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["id"] == 2
|
||||||
|
assert len(body["result"]["tools"]) == 2
|
||||||
|
|
||||||
|
async def test_jsonrpc_tools_call(self):
|
||||||
|
"""场景 12: tools/call via JSON-RPC → 200 + 结果。"""
|
||||||
|
app = _make_app(tool_registry=_make_registry_with_two_tools())
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp/",
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "echo", "arguments": {}},
|
||||||
|
"id": 3,
|
||||||
|
},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["id"] == 3
|
||||||
|
assert "content" in body["result"]
|
||||||
|
|
||||||
|
async def test_jsonrpc_unknown_method_returns_error_32601(self):
|
||||||
|
"""场景 13: 未知 method → error code -32601。"""
|
||||||
|
app = _make_app(tool_registry=None)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp/",
|
||||||
|
json={"jsonrpc": "2.0", "method": "unknown", "id": 4},
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"]["code"] == -32601
|
||||||
|
assert body["id"] == 4
|
||||||
|
|
||||||
|
async def test_jsonrpc_parse_error_returns_error_32700(self):
|
||||||
|
"""场景 14: 无效 JSON → error code -32700。"""
|
||||||
|
app = _make_app(tool_registry=None)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/mcp/",
|
||||||
|
content=b"this is not json",
|
||||||
|
headers={
|
||||||
|
"X-API-Key": API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["error"]["code"] == -32700
|
||||||
|
assert body["id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 15: MCPServer 向后兼容 ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPServerDeprecation:
|
||||||
|
"""旧 MCPServer 类应发出 DeprecationWarning。"""
|
||||||
|
|
||||||
|
def test_create_app_emits_deprecation_warning(self):
|
||||||
|
"""场景 15: MCPServer()._create_app() 发出 DeprecationWarning。"""
|
||||||
|
server = MCPServer()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
server._create_app()
|
||||||
|
dep_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
||||||
|
assert len(dep_warnings) >= 1
|
||||||
|
assert "create_mcp_router" in str(dep_warnings[0].message)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 16-17: create_mcp_router 工厂 ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateMcpRouter:
|
||||||
|
"""create_mcp_router() 工厂行为。"""
|
||||||
|
|
||||||
|
def test_create_mcp_router_returns_apirouter(self):
|
||||||
|
"""场景 16: create_mcp_router() 返回 APIRouter 实例。"""
|
||||||
|
router = create_mcp_router(tool_registry=None)
|
||||||
|
assert isinstance(router, APIRouter)
|
||||||
|
# 验证路由数量:tools/list, tools/call, health, /(JSON-RPC)
|
||||||
|
paths = {route.path for route in router.routes}
|
||||||
|
assert "/tools/list" in paths
|
||||||
|
assert "/tools/call" in paths
|
||||||
|
assert "/health" in paths
|
||||||
|
assert "/" in paths
|
||||||
|
|
||||||
|
async def test_empty_registry_returns_empty_list(self):
|
||||||
|
"""场景 17: tool_registry=None → /tools/list 返回 {"tools": []}。"""
|
||||||
|
app = _make_app(tool_registry=None)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/mcp/tools/list",
|
||||||
|
headers={"X-API-Key": API_KEY},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"tools": []}
|
||||||
Loading…
Reference in New Issue