Django 接入 MCP 实战:让 AI 安全调用数据库和业务接口

让 AI 查询订单、创建客户或更新工单,最危险的做法是把数据库连接直接交给模型。模型不理解你完整的租户规则、对象权限和副作用边界,一条看似合理的 SQL 就可能绕开业务校验。
更稳妥的结构是:MCP 只暴露少量业务工具,工具继续调用 Django 已有的 REST 接口,认证、序列化器、权限、审计和事务仍由原系统负责。本文用本地 RuyiDjangoCRM 的真实 MCP 实现做验证,相关工具、鉴权和注册测试 25 项全部通过。
MCP 不是数据库万能遥控器

这条链路有三个原则:
- AI 看到的是
crm_search、crm_get、crm_update等业务工具,不是任意 SQL。 - MCP 工具调用 Django API,而不是复制一套 ORM 权限逻辑。
- 每个请求携带当前用户自己的访问令牌,服务端不保存一枚全员共享令牌。
如果 MCP 直接访问数据库,Django REST framework 中已经存在的字段校验、对象权限、操作日志和限流都会失效。短期代码看起来少,长期会出现两套互相漂移的业务规则。
先封装一个按请求身份工作的客户端
python
import httpx
class CrmClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.headers = {
"Authorization": f"Bearer {token}",
"X-Client": "mcp",
"Accept": "application/json",
}
async def request(self, method, path, **kwargs):
async with httpx.AsyncClient(timeout=30) as client:
response = await client.request(
method,
f"{self.base_url}{path}",
headers=self.headers,
**kwargs,
)
response.raise_for_status()
return {} if response.status_code == 204 else response.json()
关键不是 Bearer 字符串本身,而是客户端必须按请求创建。若把第一个用户的客户端缓存成全局对象,后续用户就可能继承他的身份与组织范围。
工具参数要窄,服务端规则要硬
查询工具可以接受实体、关键词、过滤条件和分页,但必须限制最大返回量:
python
MAX_LIMIT = 50
async def crm_search(client, entity, query=None, filters=None, limit=20):
params = dict(filters or {})
if query:
params["search"] = query
params["limit"] = min(max(int(limit), 1), MAX_LIMIT)
return await client.request("GET", resolve_path(entity), params=params)
写操作仍由 Django 序列化器校验字段。删除、发送邮件等不可逆或对外操作,还要增加一次明确确认:
python
async def crm_delete(client, entity, object_id, confirm=False):
if not confirm:
raise ValueError("删除是破坏性操作,请传入 confirm=true。")
return await client.request("DELETE", resolve_path(entity, object_id))
async def crm_action(client, entity, object_id, action, params=None, confirm=False):
allowed = ENTITY_ACTIONS.get(entity, ())
if action not in allowed:
raise ValueError(f"当前实体不允许操作:{action}")
if action in {"send", "convert"} and not confirm:
raise ValueError("该操作会产生真实外部影响,需要再次确认。")
return await client.request(
"POST",
f"{resolve_path(entity, object_id)}{action}/",
json=params or {},
)

confirm=true 不是完整授权,只是防止模型误触。最终是否允许执行,仍由 Django 根据当前用户、组织、对象状态和角色做决定。
在 Django ASGI 入口挂载 MCP
HTTP 方式的 MCP 可以和 Django 共用一个 ASGI 服务。入口按 /mcp 分流,并在请求进入 MCP 层前拒绝缺少令牌的连接:
python
async def application(scope, receive, send):
path = scope.get("path", "")
if scope.get("type") == "http" and path.startswith("/mcp"):
token = bearer_from_headers(scope.get("headers", []))
if token is None:
await send_unauthorized(send)
return
await mcp_app(scope, receive, send)
return
await django_application(scope, receive, send)
RuyiDjangoCRM 还采用了"可选挂载":未安装 MCP 依赖或显式关闭功能时,ASGI 入口回退到普通 Django 应用,不让一个可选的 AI 能力拖垮整个站点。
工具描述也属于安全边界
工具名和参数说明会直接影响模型如何调用。建议做到:
- 查询、读取、创建、更新、删除分成不同工具,不提供万能
execute; - 枚举允许的实体和动作,未知值立即拒绝;
- 对搜索结果、文本长度和批量数量设置上限;
- 返回稳定、紧凑的 JSON,不把异常堆栈和内部配置暴露给模型;
- 在 Django 层记录用户、工具、对象、结果和请求编号,令牌本身绝不入日志。
MCP 官方规范也强调,工具调用应保留人在环确认能力,服务端必须验证输入并实施访问控制。不要因为请求来自 AI,就降低普通 API 的安全标准。
本地验证说明了什么
定向测试覆盖了 Bearer 提取、缺失身份拒绝、工具注册、查询上限、未知动作拒绝、删除确认和对外操作确认,共 25 项全部通过。另一个最小工具实验验证:未授权订单返回拒绝,授权用户只能读取自己可见的订单状态。
结论
真正值得复用的不是某个 MCP SDK 的装饰器写法,而是这条稳定边界:AI 负责选择工具和组织参数;MCP 负责协议与工具目录;Django 继续拥有身份、权限、业务规则、数据库和审计。 这样即使以后更换模型或 MCP 客户端,核心业务边界也不会被推倒重写。