谁在何时调用了什么——审计日志、模式校验与超时
一句话总结
在生产环境中运行的 MCP 服务器,还需要再有四样东西。记录每一次调用的审计日志、执行前的参数 schema 校验、按工具设置的执行时间上限、对破坏性工具的调用频率限制。规范把前两项和最后一项写作服务器的 MUST,把超时写作客户端的 SHOULD,而在实际工作中,四样都由服务器来具备更安全。
为什么需要它
出了事故之后的第一个问题是“谁、在什么时候、用什么参数调用了什么”。没有这个答案,原因只能靠猜测来填补,而靠猜测修好的服务器会再出同样的事故。之前模块的事故记录里之所以能写下 cause=,是因为碰巧留有请求日志,而真正的生产环境中不能依赖这种碰巧。
工具规范的安全考虑一节写道,服务器必须验证所有工具输入、实现访问控制、限制调用频率并净化输出(MUST)。对客户端则建议:敏感操作需用户确认、调用前展示输入、校验结果、工具调用的超时,以及留下用于审计的使用记录(SHOULD)。本模块把这份清单搬到服务器一侧的代码里——因为服务器无法选择客户端是哪个宿主。
工作原理
审计日志以调用为单位。包住执行工具的那个函数,记录开始时间,根据结果的 isError 判断成功与否,再把一行 JSON 追加到文件中。要记录的有:何时(ts)、什么(tool)、用什么参数(arguments)、成功与否(ok)、耗时多久(duration_ms),若失败则记录原因(error)。因协议错误而被拒绝的调用也要记录——反复发送错误参数的客户端本身就是一个信号。让路径通过环境变量接收,评分器和测试就都可以用临时文件来运行。
schema 校验在执行之前。tools/list 给出的 inputSchema 是对模型作出的承诺,服务器自己遵守这个承诺,就是校验。缺了 required 所列的项,或者 type 不符时,不执行,而是以 JSON-RPC 2.0 的 -32602 Invalid params 回答。这不是工具在运行中失败,而是请求有误,所以是协议错误,而不是 isError。Python 里有一点要注意——bool 是 int 的子类型,所以 isinstance(True, int) 为真。在 integer 的位置传入 true 的情况,需要另外拦截。
TYPES = {"string": str, "integer": int, "boolean": bool}
for key in schema.get("required", []):
if key not in args:
raise RpcError(-32602, f"Invalid params: missing {key}")
for key, spec in schema["properties"].items():
if key in args and not isinstance(args[key], TYPES[spec["type"]]):
raise RpcError(-32602, f"Invalid params: {key} must be {spec['type']}")
超时由服务器自己设置。生命周期中的超时一节要求对发出的每个请求都设置超时,以防连接卡死和资源耗尽,并要求即使收到进度通知,也必须遵守最长超时。这虽说的是客户端,但服务器出于同样的理由也要给自己的工具设上限——因为一个卡住的工具,就会把整个单线程服务器占住。用标准库的 signal.alarm 让进程在 N 秒后收到 SIGALRM,在处理函数里抛出异常,即使在 time.sleep 或慢查询的过程中也能脱身。结束后用 signal.alarm(0) 解除。超出时间的调用以 isError: true 回答,并在审计日志中记为失败。
日志也通过协议发送。日志工具规定,服务器要声明 logging 能力,并通过 notifications/message 通知发送结构化日志。level 是 RFC 5424 的八个级别(debug、info、notice、warning、error、critical、alert、emergency),并附带 logger 和 data。它是通知,所以没有 id。如果说 stderr 日志是给人看的,那么这是宿主以结构化方式接收、然后显示在界面上或收集起来的日志。每次调用时在响应之前发送一行即可。
调用频率限制从破坏性工具开始。会话就是一个进程,所以用模块级的全局字典按工具计数即可。把 delete_order 限制为每个会话 3 次,陷入错误循环的智能体想删光所有订单,也会在删到第 3 条时停下。超出的调用不删除,以 isError: true 回答。
在现场相遇的样子
有了审计日志,就需要能读它的工具。只要有一个统计每个工具的调用数和失败数的脚本,就能回答“过去一小时里 delete_order 被调用了多少次、被拒绝了多少次”,这些数字就是告警规则的原料。失败率突然升高,说明改了 schema 却没改模型那边的说明;超时增多,说明后面的数据库变慢了。
超时值因工具而异。查询 2 秒就够,但生成报告可能需要 30 秒。顺序应该是先用一个值开始,再根据审计日志中 duration_ms 的分布按工具拆开——一开始就猜测着给每个工具填不同的值,代码里就会留下没有依据的数字。
下一项实验要做什么
在上一个模块的服务器中,依次加入审计日志、schema 校验、slow_report 工具和 2 秒上限、日志通知、delete_order 的 3 次限制,最后写一个脚本,读取审计日志,汇总每个工具的调用数和失败数。