"""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 from agentkit.tools.base import Tool logger = logging.getLogger(__name__) # MCP 端点要求至少 member 权限(CHAT)。API Key 与 JWT 均可。 # 认证由主 app 的 AuthMiddleware 处理,这里只做权限校验。 _mcp_member_auth = require_permission(Permission.CHAT) # ponytail: 危险工具黑名单 — 这些工具依赖 chat confirmation 流程(WebSocket), # 通过 MCP 暴露会绕过用户确认机制。黑名单需手动维护,新增危险工具需同步更新。 # 天花板:若工具名动态生成或别名注册,黑名单可能漏判。 # 升级路径:Tool 基类增加 requires_confirmation 元数据字段,按属性自动过滤。 # U5c: 与 publisher.py 的 _DANGEROUS_TOOL_NAMES 保持一致(单一真相源)。 _MCP_BLOCKED_TOOLS: frozenset[str] = frozenset( { "terminal", # 终端执行(与 ShellTool 协同)— 危险命令需 confirmation "shell", # ShellTool — 危险命令需 confirmation "file_write", # 文件写入 "file_read", # 文件读取(可能泄露敏感配置) "file_delete", # 文件删除 } ) def _serialize_tool(tool: Tool) -> dict[str, Any]: """将 Tool 序列化为 MCP 协议响应字典。""" return { "name": tool.name, "description": tool.description, "inputSchema": tool.input_schema or {}, } def create_mcp_router( tool_registry: Any = None, published_tools_getter: Any = None, ) -> APIRouter: """构造 MCP 路由,挂载到主 app 的 ``/api/v1/mcp/`` 前缀下。 所有端点要求至少 member 权限(Permission.CHAT)。认证由主 app 的 AuthMiddleware 处理(JWT + API Key 双轨)。 Args: tool_registry: ToolRegistry 实例。若为 None,所有工具相关 端点返回空列表或错误。 published_tools_getter: U14 可选回调,返回通过发布端点注册的 ``list[Tool]``。这些工具会与 ``tool_registry`` 中的工具合并 后一起对外暴露。向后兼容:默认 None 时行为与 U13 一致。 Returns: APIRouter,包含以下路由: - GET /tools/list — 列出 MCP 工具 - POST /tools/call — 调用 MCP 工具 - GET /health — 健康检查 - POST / — JSON-RPC 2.0 端点(MCP 协议兼容) """ 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") # 过滤危险工具 — 防止绕过 chat confirmation 流程 return [t for t in tools if t.name not in _MCP_BLOCKED_TOOLS] def _find_tool(name: str) -> Tool | None: """按名查找工具(先 registry 后已发布),危险工具返回 None。""" if name in _MCP_BLOCKED_TOOLS: logger.warning(f"MCP tool '{name}' blocked — requires chat confirmation flow") return None 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 工具。""" tools = _all_tools() return {"tools": [_serialize_tool(t) 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: raise HTTPException( status_code=400, detail="Tool not specified or registry not configured", ) tool = _find_tool(tool_name) if tool is None: 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": tools = _all_tools() result = {"tools": [_serialize_tool(t) for t in tools]} elif method == "tools/call": tool_name = params.get("name", "") arguments = params.get("arguments", {}) if not tool_name: result = { "isError": True, "content": [{"type": "text", "text": "Tool not found"}], } elif tool_name in _MCP_BLOCKED_TOOLS: # 显式黑名单检查 — 与 legacy JSON-RPC 一致,提供清晰审计反馈 logger.warning(f"MCP tool '{tool_name}' blocked — requires chat confirmation flow") result = { "isError": True, "content": [{"type": "text", "text": f"Tool '{tool_name}' is blocked via MCP"}], } else: 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", "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: """[DEPRECATED] 独立 MCP Server — 使用 create_mcp_router() 替代。 保留用于向后兼容(独立 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): self._tool_registry = tool_registry self._host = host self._port = port self._app = None def _create_app(self): """创建 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 except ImportError: raise ImportError("MCP Server requires fastapi: pip install fischer-agentkit[mcp]") app = FastAPI(title="Fischer AgentKit MCP Server") @app.get("/tools/list") async def list_tools(): if self._tool_registry is None: return {"tools": []} tools = self._tool_registry.list_tools() # 过滤危险工具(与 create_mcp_router 一致) tools = [t for t in tools if t.name not in _MCP_BLOCKED_TOOLS] return {"tools": [_serialize_tool(t) for t in tools]} @app.post("/tools/call") async def call_tool(request: dict): tool_name = request.get("name") arguments = request.get("arguments", {}) if not tool_name or self._tool_registry is None: return {"error": "Tool not specified or registry not configured"} if tool_name in _MCP_BLOCKED_TOOLS: return {"error": f"Tool '{tool_name}' is blocked via MCP"} try: tool = self._tool_registry.get(tool_name) result = await tool.safe_execute(**arguments) return {"content": [{"type": "text", "text": str(result)}]} except Exception as e: return {"isError": True, "content": [{"type": "text", "text": str(e)}]} @app.get("/health") async def health(): return {"status": "ok"} @app.post("/") async def jsonrpc_endpoint(request: Request): """JSON-RPC 2.0 endpoint for MCP protocol compatibility. Handles requests from HTTPTransport which sends JSON-RPC format. """ 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 self._tool_registry is None: result = {"tools": []} else: tools = self._tool_registry.list_tools() result = { "tools": [ _serialize_tool(t) for t in tools if t.name not in _MCP_BLOCKED_TOOLS ] } elif method == "tools/call": tool_name = params.get("name", "") arguments = params.get("arguments", {}) if not tool_name or self._tool_registry is None: result = { "isError": True, "content": [{"type": "text", "text": "Tool not found"}], } elif tool_name in _MCP_BLOCKED_TOOLS: result = { "isError": True, "content": [ {"type": "text", "text": f"Tool '{tool_name}' is blocked via MCP"} ], } else: try: tool = self._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, } response = {"jsonrpc": "2.0", "result": result, "id": req_id} return response return app async def start(self): """启动 MCP Server""" self._app = self._create_app() try: import uvicorn config = uvicorn.Config(self._app, host=self._host, port=self._port, log_level="info") server = uvicorn.Server(config) await server.serve() except ImportError: raise ImportError("MCP Server requires uvicorn: pip install uvicorn") def get_app(self): """获取 FastAPI 应用实例(用于测试或嵌入)""" if self._app is None: self._app = self._create_app() return self._app