fischer-agentkit/src/agentkit/mcp/server.py

374 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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