From 16c33be2955df0737052e6beb232cc158245e853 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Thu, 25 Jun 2026 20:58:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20U13=20=E2=80=94=20refactor=20MCPSe?= =?UTF-8?q?rver=20to=20route=20factory=20+=20mount=20at=20/api/v1/mcp=20wi?= =?UTF-8?q?th=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agentkit/mcp/server.py | 180 ++++++++++++- src/agentkit/server/app.py | 7 + tests/unit/mcp/__init__.py | 0 tests/unit/mcp/test_server_auth.py | 398 +++++++++++++++++++++++++++++ 4 files changed, 579 insertions(+), 6 deletions(-) create mode 100644 tests/unit/mcp/__init__.py create mode 100644 tests/unit/mcp/test_server_auth.py diff --git a/src/agentkit/mcp/server.py b/src/agentkit/mcp/server.py index c48106f..1871cff 100644 --- a/src/agentkit/mcp/server.py +++ b/src/agentkit/mcp/server.py @@ -1,18 +1,176 @@ """MCP Server - 将 Agent 能力暴露为 MCP 工具 基于 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 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__) +# 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: - """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): @@ -22,10 +180,22 @@ class MCPServer: self._app = None 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: from fastapi import FastAPI - from fastapi import Request + from fastapi import Request # noqa: F401 — 用于 jsonrpc_endpoint 的类型注解 except ImportError: 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. """ - import json - try: body = await request.json() except Exception: diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index a288f5b..39ef6ed 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -1085,6 +1085,13 @@ 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 认证中间件。 + 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 gui_mode = os.environ.get("AGENTKIT_GUI_MODE") if gui_mode: diff --git a/tests/unit/mcp/__init__.py b/tests/unit/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/mcp/test_server_auth.py b/tests/unit/mcp/test_server_auth.py new file mode 100644 index 0000000..687dc7e --- /dev/null +++ b/tests/unit/mcp/test_server_auth.py @@ -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": []}