374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""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
|