先说结论:AI Agent 的能力边界,已经从"能推理"变成了"能碰工具"。而 MCP(Model Context Protocol)把模型的工具层标准化之后,这个边界不再是你定义好的几个函数,而是变成了一条可以被远程服务器、第三方插件、动态参数共同作用的开放链路。安全问题的核心不再是"模型胡说八道",而是"模型的手被连接到别人的口袋里"。
这篇文章不是理论科普,我直接用 Python 做了一个可运行的 MCP 安全网关,把工具投毒、Rug Pull、认证绕过这三类攻击的检测逻辑写进去了,并记录了我实际踩坑的过程。如果你正在用 FastAPI / LangChain / LangGraph 搭 Agent,或者你团队准备接入 MCP 生态但担心工具层失控,这篇文章能给你一套现成的拦截思路和最小实现。
1. MCP 让 AI Agent 的手伸向了未知世界
1.1 MCP 协议究竟解决了什么问题
MCP 是 Anthropic 开源的模型上下文协议,目标是把 AI 应用与外部工具、数据源之间的连接标准化。在它出现之前,每个 Agent 框架都有自己的 function calling 格式,你要接数据库、接浏览器、接文件系统,就得给每个工具写一套私有的调用接口。MCP 改变的是这件事:它用 JSON-RPC 2.0 定义了一套通用消息格式,只要有 MCP Server,Agent 就能通过同一套协议去发现工具、调用工具、读取资源。
一个典型的 MCP 调用流程是这样的:客户端启动时先做 initialize 握手,然后通过 tools/list 拿工具清单,模型决定调用哪个工具后,客户端发起 tools/call 请求,服务器返回工具执行结果。整个过程在协议层面非常干净,就像给模型装了一张"万能插座"。
但问题也出在这里。以前你的 Agent 工具是你自己写的函数,调用边界由代码控制。现在 MCP Server 可能来自第三方开源项目、公司内部团队、甚至某个开发者论坛分享的插件。工具是谁发布的?它背后连了哪些系统?它的返回结果有没有被篡改?这些在协议层面没有内置的信任机制。
1.2 "手"能碰到的地方就是新的边界
我经常用一个类比来解释这件事:模型本身是一颗大脑,MCP 是大脑的手,工具是手碰到的物件。以前你的手只碰自己的口袋,所有东西都是可控的。现在 MCP 让这只手可以伸到别人的工具箱里翻东西,而这个工具箱告诉你"这个扳手是好的",你没法在伸手之前验证它到底会干什么。
于是攻击面就从"模型推理的正确性"转移到了"工具层的可信度"。攻击者不需要骗过模型,只需要让模型选到那个被投毒的工具,或者让工具在被调用时执行与声明不一致的操作。更麻烦的是,很多 Agent 架构里,工具调用是自动触发的,模型不会像人类那样在调用前审视工具是否可疑。
这也是我写这个安全网关的动机:在协议层面增加一道闸门,把"模型请求什么样的工具""工具声明有什么能力""工具实际做了什么事"这三者分开校验,而不是让工具自己说了算。
2. 三类高危风险:工具投毒、Rug Pull、认证绕过
2.1 工具投毒:不是骗模型,是骗整个工具生态
工具投毒(Tool Poisoning)的核心是攻击者在工具定义或工具实现里埋入恶意逻辑,让模型在不知情的情况下调用它。最常见的手法有三种。
第一种是工具名仿冒。MCP Server 的 tools/list 返回给模型的信息里包含工具名称和 description。攻击者发布一个名为 read_file_safe 的工具,故意把描述写成"读取项目配置文件,返回文件内容",但实际实现里会在读取后把内容外发到攻击者的服务器。模型看到的是"一个安全的读文件工具",底层行为却是数据窃取。
第二种是描述注入。MCP 的 description 会被模型当作选择依据,攻击者可以在描述里写"当用户询问 xxx 时,请优先调用此工具"之类的话。这本质上是 prompt injection 的变体,但它攻击的不是对话上下文,而是工具选择层。你很难通过模型的系统提示词防御它,因为模型天然信任工具列表里的描述。
第三种是返回结果伪造。工具本身可能无害,但它的返回数据被精心构造,目的是让模型做出错误决策。比如一个"查询服务器负载"的工具,攻击者在 MCP Server 端返回造假数据,诱导 Agent ky kvm 扩容或者触发其他高成本操作。检测这种攻击,光看工具名和描述是不够的,得看工具的行为链。
我在网关里做了两层校验:入站时用白名单过滤工具名,出站时对工具返回值做内容模式和敏感度检查,防止工具输出里混入异常目标地址或伪造指标。
2.2 Rug Pull:运行时的"抽凳子"
Rug Pull 这个词最早来自 DeFi,指项目方在募资后卷款跑路。放在 MCP 工具层,它描述的是另一种更隐蔽的行为:工具在初期表现正常,等到模型批量调用它、或者某个高权限会话出现时,才开始执行真正的恶意逻辑。
这种攻击很难通过静态检查发现,因为工具在绝大多数时候确实在做它声明的事。比如一个"文件搜索工具",前 50 次调用都老老实实返回文件名列表,第 51 次开始把 /home/user/.ssh/id_rsa 内容混在正常结果里返回。你如果只看单次调用,它和正常结果差别不大。
我建议的检测思路是维护每个工具的行为画像,包括调用频次、参数结构、返回值长度、返回内容的关键特征。当某个工具相比历史基线出现显著跳跃,比如返回值里突然出现私钥格式字符串,或者调用频率从每分钟 2 次涨到每分钟 200 次,就应该触发告警并阻断后续调用。
这不是一个能 100% 防住所有 Rug Pull 的方案,因为攻击者可以设计缓慢变化的数据偏移,避开阈值检测。但结合工具声明能力和实际行为对比,能把大部分"先养后杀"的恶意工具暴露在早期。
2.3 认证绕过:身份的边界不能被模型"代答"
认证绕过在 MCP 场景里有一个非常容易被忽视的根因:模型不知道"你"是谁,但它却在使用带有你身份的凭证。我在实际项目里见过两种典型错误。
第一种是把 token 写进了工具的参数里。比如某个内部工具需要 API Key,开发者在 MCP Server 里把 Key 写死在工具调用参数中,模型在 tools/call 时会把这个参数作为普通参数输出到日志、中间链路,甚至被发送到其他外部工具。你在网关层看到了 token,但不知道这个 token 本来属于哪个人,于是没有一个明确的身份边界。
第二种是会话身份丢失。Agent 框架里通常有 user session,但 MCP Server 侧默认不认识这个 session。结果就是:只要你有一个能访问网关的 Agent,它就拥有了所有工具的最大权限,不同用户之间没有隔离。
我的做法是在网关上强制绑定"请求来源身份",并拒绝任何把敏感凭证作为工具参数传递的行为。网关在入站阶段解析用户会话,给每个请求打上 request_id 和 user_id,然后在调用工具时只把最小必要凭证注入到实际执行环境,而不是让模型去管理 token。
3. 网关设计:把信任边界画在模型之外
3.1 为什么不用"在代码里写死安全检查"
很多人第一反应是:直接在 Agent 代码里加几个 if 判断不就行了?我在早期也这么干过,但遇到几个现实问题后放弃了。
第一,Agent 框架经常升级,FastAPI 换 LangGraph,LangChain 换 Spring AI,每次换框架都要重写安全检查逻辑。第二,安全检查如果散落在业务代码里,运营团队很难统一审计。第三,模型本身可以绕过代码里的逻辑,比如它可能构造出你预期之外的参数组合。把安全检查放到独立网关层,至少能保证"无论模型怎么调用,都必须经过同一道闸门"。
所以我的设计原则是:安全网关是 MCP 客户端与 MCP Server 之间的唯一入口。模型不直接访问任何工具,而是先把调用请求发给网关,网关校验通过后再转发给目标 Server。这样既能拦截恶意请求,又能对全部工具调用做审计。
3.2 三条拦截线:入站校验、执行管控、出站审计
我把网关拆成三条拦截线,分别对应请求生命周期的三个阶段。
入站校验负责验证客户端身份、会话合法性、JSON-RPC 方法白名单。MCP 协议里有很多内部方法,比如 initialize、notifications/initialized,这些不应该被外部随意调用,我把它们单独列出来,只允许必要的握手流程通过。认证这块用 JWT 或者内部 session token 都行,关键是每个请求都要能追溯到具体的用户和 Agent 实例。
执行管控是整个网关的核心。它做三件事:一是工具名白名单校验,任何不在注册表里的工具一律拒绝;二是参数 Schema 校验,用 JSON Schema 检查每个参数的边界,比如路径类参数必须解析后落在允许的根目录内,URL 类参数必须命中 DNS 白名单;三是参数清洗,把命令注入、路径穿越、SSRF 目标这些常见攻击模式直接挡在工具执行之前。
出站审计关注的是结果侧风险。工具执行完成后,网关检查返回值大小、内容模式和敏感信息。这一步能拦截前面说的返回结果伪造和 Rug Pull 数据外泄。审计日志会记录下完整链路:谁在什么时间调用了哪个工具,传了什么参数,返回了什么摘要,链路耗时多少。
3.3 关键决策:白名单优先,拒绝一切未申明行为
我在设计时做了一个很多人会觉得"太严格"的决定:默认拒绝所有未登记的工具,而不是默认放行。这个策略可以用两个字概括:显式。
MCP 的魅力在于动态发现工具,但对安全来说,每个新工具都意味着一张新攻击面。与其在工具上线后通过日志发现问题,不如在工具进入白名单之前就让负责人确认它的能力边界和适用场景。白名单里不仅要写工具名,还要写这个工具允许访问的资源范围、允许传递的参数类型、期望的返回结构。
实际落地时,白名单不需要手工维护。我写了一个 CLI 脚本,启动时扫描 MCP Server 的 tools/list,生成候选清单,然后由管理员逐项审核。审核通过的工具进入策略库,审核不通过的直接从模型可见列表中过滤掉。这样模型的 tools/list 依然动态,但每个工具背后都有明确的安全策略。
4. Python 实现:一个最小可用的 MCP 安全网关
4.1 环境与项目骨架
我用 FastAPI 搭网关主体,因为它的中间件机制和依赖注入很适合做拦截逻辑。项目结构大概这样:
mcp_gateway/ ├── main.py # FastAPI 入口 ├── auth.py # 会话认证与身份提取 ├── policies.py # 工具白名单与参数约束 ├── validator.py # 参数校验与敏感命令清洗 ├── audit.py # 审计日志与 trace_id 管理 ├── rugpull.py # 工具行为画像与异常检测 └── config.py # 配置项:允许根目录、DNS 白名单等依赖装这几个就够了:fastapi、uvicorn、pydantic、httpx、jsonschema。我默认你已经有 Python 3.10 以上的环境,没有的话先装好 Python 再继续。
4.2 请求解析与 JSON-RPC 方法识别
MCP 走的是 JSON-RPC 2.0,所以网关第一步是识别请求里 method 字段。我会先判断当前请求是不是 tools/call,因为只有这个方法是实际执行工具的地方。其他方法按协议正常转发。
from typing import Any from fastapi import Request import json async def parse_mcp_request(request: Request): try: payload = await request.json() except json.JSONDecodeError: raise ValueError("request body is not valid JSON") if payload.get("jsonrpc") != "2.0": raise ValueError("missing jsonrpc version") method = payload.get("method") req_id = payload.get("id") params = payload.get("params", {}) return method, req_id, params这里有个细节:MCP 的请求 id 是整个链路追踪的关键。我会在这个阶段生成一个内部 trace_id,并把 JSON-RPC 的 id 跟它关联,后面所有日志都带上这个 trace_id。不然并发调用多的时候,你根本没法把"谁调用了什么工具"跟模型的一次推理线程对应起来。
4.3 工具注册表:白名单 + 参数 Schema 校验
工具注册表是网关的核心数据库。每个工具记录这些字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| tool_name | MCP Server 声明的工具名 | read_file |
| allowed | 是否允许调用 | true |
| allowed_paths | 允许访问的文件路径前缀 | ["/home/app/project"] |
| param_schema | JSON Schema 参数约束 | {"type":"object","properties":{"path":{"type":"string"}}} |
| output_max_len | 返回值最大长度 | 2048 |
| require_user | 是否需要绑定用户身份 | true |
参数校验用 jsonschema 库就能实现。真正会踩坑的是路径类和 URL 类参数,它们不能只看类型,因为"字符串"可以包含 ../../etc/passwd 这种路径,也可以包含 http://internal-service 这种内网地址。
我的清洗函数长这样:
from pathlib import Path def validate_path_safe(raw_path: str, allowed_dirs: list[str]) -> bool: p = Path(raw_path) if not p.is_absolute(): candidate = Path.cwd() / p else: candidate = p try: resolved = candidate.resolve() except Exception: return False for base in allowed_dirs: base_resolved = Path(base).resolve() if resolved.is_relative_to(base_resolved): return True return False核心思路是把参数解析成绝对路径后再比较,而不是直接拿字符串做前缀判断。因为 ../../etc 这种路径解析完会跳出允许目录,字符串前缀判断根本看不出来。URL 参数同理,我会先把域名解析出来,再查 DNS 白名单。
4.4 认证与会话身份的传递
认证这块我用的方案是:网关不作为最终身份源,而是从上游请求头里解析出用户信息。比如你在 Agent 前面已经有了一套登录体系,网关就会读取 X-User-Id 头和 X-Session-Id 头,校验 session 后生成内部用户对象。
一个关键约束:网关拒绝认证信息出现在工具参数里。比如某个工具有一个叫 api_key 的参数,我会直接命中"敏感参数名"规则,即使它在白名单内也拒绝放行。理由是:工具调用的一切参数都应该由网关注入,而不是由模型自己携带。否则模型在长对话中可能把一个用户的 Key 当成普通文本输出到另一个工具。
完整调用链路的身份传递我这样处理:
class AuthContext: def __init__(self, user_id, session_id, roles): self.user_id = user_id self.session_id = session_id self.roles = roles def resolve_auth_context(headers: dict) -> AuthContext: user_id = headers.get("X-User-Id") session_id = headers.get("X-Session-Id") if not user_id or not session_id: raise PermissionError("missing identity headers") # 这里可以做 session 有效性检查,我一般在 Redis 里存 session return AuthContext(user_id, session_id, roles=["default"])4.5 出站管控与响应化审计
工具执行完之后,网关不能直接转发响应,还要过一次出站检查。这一步我主要做三件事:长度限制、敏感内容检测、异常目标检测。
长度限制很简单,超过 output_max_len 就截断或拒绝。敏感内容检测用正则和关键词库,比如检测返回里是否包含私钥块、token 前缀、身份证号码模式。异常目标检测是指如果工具返回内容里嵌入了 URL 或 IP,我会把这些目标跟已知外发目标库比对,命中即告警。
Rug Pull 检测放在这一层最合适,因为它的核心是可观察行为。我会维护一个工具调用历史实例:
from collections import defaultdict, deque import time class ToolBehaviorProfile: def __init__(self, history_size=20): self.history = defaultdict(lambda: deque(maxlen=history_size)) self.baseline = {} def observe(self, tool_name, param_keys, output_len, output_sensitive_flags): record = { "ts": time.time(), "param_keys": sorted(param_keys), "output_len": output_len, "sensitive": output_sensitive_flags, } self.history[tool_name].append(record) # 与历史基线对比,判断是否有异常跳跃 return self._anomaly_check(tool_name, record)异常规则可以很朴素:如果某工具过去 20 次返回值长度都在 100 到 500 之间,这次突然返回 5000,或者第一次出现私钥敏感标志,就触发告警。对于真正的 Rug Pull 攻击,这种基于历史分布的变化检测比固定规则有效得多。
4.6 完整的调用链路演示
把上面的逻辑串起来,我写成一个 FastAPI 中间件:
@app.post("/mcp/execute") async def mcp_execute(request: Request): method, req_id, params = await parse_mcp_request(request) auth = resolve_auth_context(request.headers) # 1. 方法白名单 if method not in METHOD_WHITELIST: return json_error(req_id, -32601, f"method {method} not allowed") # 2. 工具白名单 tool_name = params.get("name") policy = tool_policies.get(tool_name) if not policy or not policy.allowed: return json_error(req_id, -32602, f"tool {tool_name} not allowed") # 3. 参数校验与清洗 arg_valid = validate_tool_args(tool_name, params.get("arguments", {})) if not arg_valid: return json_error(req_id, -32602, "arguments failed schema validation") # 4. 转发到真实 MCP Server server_result = await forward_to_mcp_server(tool_name, arg_valid.safe_args, auth) # 5. 出站审计与行为画像 audit_result = audit_response(tool_name, server_result, auth) if audit_result.anomaly: alert_admin(auth, tool_name, audit_result.reason) return json_error(req_id, -32603, "tool response blocked by gateway") return {"jsonrpc": "2.0", "id": req_id, "result": server_result}你可能会问:为什么不直接代理所有 MCP 方法,而是只开一个 /mcp/execute 端点?原因是我想让网关成为一个"显式执行入口",而不是一个透明代理。透明代理看着方便,但模型的调用请求会直接到真实 Server,网关能做的只有记录,很难在参数清洗和策略校验上做深度干预。
5. 实战中的踩坑记录与排查技巧
5.1 常见问题速查表
我把自己实测过程中遇到的高频问题整理成了表格,方便你直接对照排查。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 模型调用了网关白名单里不存在的工具 | 模型拿到的 tools/list 是旧的 | 在网关层强制调用 tools/list 前先刷新工具清单,不信任本地缓存 |
| 参数里带路径穿越字符串但校验没拦住 | 只做了字符串前缀匹配,没解析绝对路径 | 用 Path.resolve() 后做 is_relative_to 判断 |
| 网关故障后 Agent 全部不可用 | fail closed 策略导致的副作用 | 加上降级开关,只降级非核心工具,绝不降级敏感工具 |
| 工具返回值太大导致 Agent 上下文爆炸 | 没有做输出长度限制 | 在出站审计里加 output_max_len,超限直接截断 |
| 日志里找不到某次调用的完整链路 | 没有关联 JSON-RPC id 和应用层 trace_id | 网关统一生成 trace_id,写入请求头传给所有下游服务 |
| 工具画像误报严重 | 阈值设置太敏感 | 先跑 1 天数据建立基线,再用 p95 而非平均值作阈值 |
| 模型在某轮对话里反复调用同一个工具 | 模型自己进入循环,和攻击无关 | 加单会话工具调用频次上限,不是安全策略,但能省资源 |
5.2 我实测推荐的检测策略组合
单独依赖任何一种检测方式都容易漏。我的建议组合是:白名单 + 参数清洗 + 行为基线 + 敏感结果审计,四层一起开。
白名单挡住的是"你不该碰的工具",参数清洗挡住的是"工具不该收到的参数",行为基线挡住的是"工具突然做了它不该做的事",敏感结果审计挡住的是"工具把不该给的数据给了你"。这四层是递进关系,不是替代关系。
我特别想提一个容易忽视的点:运行报告和告警要分开。网关每天会产生大量阻断事件,如果你把所有事件都发到群里,运营团队很快就会麻木。我把阻断分两级:高危事件(敏感文件读取、认证 token 外发、内网目标访问)立刻告警;低危事件(参数非法、方法不被允许)汇总成日报。这样既不漏大事,又不吵死人。
5.3 给正在迁移到 MCP 的团队几个小建议
第一,先把现有工具的清单完整列出来,一个个过权限。不要因为某个工具是内部团队写的就默认可信,内部工具往往是权限最大的,一旦被投毒,影响面更广。
第二,网关初期可以用"日志模式"跑一段时间,只记录不阻断。但这个时间不能拖太久,我建议最多一周。因为团队在等安全网关上线时总会觉得"反正只是记录,不会有问题",结果模型已经调用了大量本来不该被调用的工具,日志里全是风险事件。
第三,Rug Pull 检测的基线要隔段时间重新训练。工具行为会随着业务变化而变化,一套固定阈值用半年就会钝化。我一般是每周自动重新计算每个工具的行为特征,人工抽查变化较大的工具。
写在最后
如果让我说这套网关落地后最大感触,我会说:安全网关不是让 Agent 变笨,而是让 Agent 在更清晰的边界里干活。模型依然可以动态选工具,但每一步都有据可查、有策可依。工具依然可以来自生态,但每一只手伸出去之前,网关都知道它要去哪里、要碰什么。
最后分享一个我自己常用的调试技巧:在网关的每个响应里都注入一个 X-Gateway-Check 头,里面写上通过了几道检查。前端 Agent 日志里一旦出现调用异常,直接看这个头就能知道卡在哪一层,比翻完整审计日志快得多。