AI 技术
#MCP#日志#JSON-RPC#Streamable HTTP#可观测性

MCP 日志级别通知:为什么服务端调高日志级别后客户端仍收不到调试日志

远程 MCP Server 排障时,服务端明明调高了日志级别,客户端却只看到 info 以上日志。文章从 logging/setLevel 与 notifications/message 的职责划分入手,解释级别未同步、通知未订阅、会话重连后级别重置三类日志缺失,并对比本地日志与轮询方案的取舍。

一个看似矛盾的排障现场

线上有一个远程 MCP Server,通过 Streamable HTTP 暴露工具。某次工具调用返回了不符合预期的结果,你想看服务端内部到底走了哪条分支。你把服务端的日志级别从 info 调到 debug,重启进程,再让客户端调用一次工具。客户端界面里仍然只有 info 和 warning,一条 debug 都没有。

直觉会认为“服务端已经调高了级别,客户端应该收到更详细的日志”。这个直觉默认了两件事:服务端本地日志级别和协议日志级别是同一个开关;客户端一定在接收并展示服务端推来的日志通知。MCP 的日志机制里,这两件事都不自动成立。

贯穿全文的场景固定为:一个远程 MCP Server 部署在容器里,客户端是桌面 AI 应用,排障目标是让客户端看到某个工具内部的 debug 日志。

两套日志开关,各管各的

MCP 的日志能力由服务端声明。服务端要发送日志消息通知,必须声明 logging 能力:

{ "capabilities": { "logging": {} } }

没有这个声明,客户端就不该期待收到 notifications/message。声明之后,协议规定了两条消息:

  • 客户端用 logging/setLevel 请求设置服务端的最低日志级别。
  • 服务端用 notifications/message 通知推送单条日志。

logging/setLevel 的请求体是:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "logging/setLevel",
  "params": { "level": "info" }
}

级别沿用 RFC 5424 的 syslog 严重性分级,从低到高依次是 debug、info、notice、warning、error、critical、alert、emergency。级别越高表示越严重。客户端设 info,服务端只应推送 info 及更高级别的消息;客户端设 debug,服务端才应把 debug 也推出来。

关键点在于:logging/setLevel 设置的是协议通知的最低级别,它不必然等于服务端进程内部日志框架的级别。服务端内部可能用 Python 的 logging、Go 的 slog 或别的库写本地日志,协议通知只是这些日志的一个出口。如果实现把两者接在一起,调协议级别会同时影响本地输出;如果没接在一起,你调本地级别不会改变协议推送,调协议级别也不会改变本地文件。

一条日志如何从服务端走到客户端

服务端推送一条日志,用的是 notifications/message:

{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "error",
    "logger": "database",
    "data": { "error": "Connection failed", "details": { "host": "localhost", "port": 5432 } }
  }
}

level 是这条消息自身的严重性,logger 是可选的日志器名称,data 是任意可序列化为 JSON 的负载。注意这是 notification,没有 id,服务端不期待客户端回复。

把整个链路画出来,可以看到级别过滤发生在服务端,而客户端只负责接收和展示:

flowchart TD
    A[客户端发起会话] --> B[客户端发送 logging/setLevel info]
    B --> C[服务端记录会话最低级别 info]
    C --> D[工具执行产生日志]
    D --> E{日志级别是否不低于 info}
    E -->|是| F[服务端发送 notifications/message]
    E -->|否| G[丢弃,不推送]
    F --> H[客户端收到并展示]
    H --> I[排障需要 debug]
    I --> J[客户端发送 logging/setLevel debug]
    J --> K[服务端更新会话最低级别 debug]
    K --> D

图中有一个容易被忽略的转折:最低级别是按会话记在服务端一侧的。客户端发一次 logging/setLevel,服务端更新的是当前会话的状态,而不是进程的全局配置。这个设计决定了后面要讲的会话重连问题。

级别未同步:调的是本地日志,不是协议级别

回到排障现场。你“把服务端日志级别调到 debug”,具体调的是什么?

如果调的是容器环境变量或配置文件里的本地日志级别,那么服务端进程会往 stdout 或日志文件写更多 debug 行。但协议推送走的是另一条路径:服务端只在收到 logging/setLevel 后才更新会话级别。客户端没发过 logging/setLevel debug,会话级别仍是默认值或上一次设置的值,debug 消息在过滤阶段就被丢掉了。

反过来也有一种情况:客户端发了 logging/setLevel debug,但服务端实现只把它记录到一个变量里,没有真正接到日志框架的过滤条件上。这时协议层面“已同意推送 debug”,实际却没有 debug 消息产生,因为内部日志根本没写。

判断方法很直接:看服务端本地日志文件里有没有对应的 debug 行。

  • 本地有 debug,客户端没有:问题在协议推送路径,检查会话级别和通知发送。
  • 本地也没有 debug:问题在本地日志级别,协议级别调了也没用。

这两条路径的排查方向完全不同,混在一起就会得出“调了级别还是没日志”的错误结论。

通知未订阅:能力声明与客户端处理

第二类缺失发生在客户端一侧。协议规定服务端要声明 logging 能力,客户端据此知道可以期待日志通知。但客户端收到 notifications/message 之后是否展示,协议没有强制。规范明确说,实现可以用任何符合自身需求的界面模式暴露日志,协议本身不规定用户交互模型。

于是出现一种情况:服务端确实在推送 debug 通知,抓包能看到 notifications/message 帧,但客户端界面不显示。原因可能是客户端没有把日志通知接到 UI,或者 UI 有独立的过滤条件,只显示 warning 以上。

排查时不要只看界面。在传输层抓一次会话,确认三件事:

  1. 服务端初始化响应里是否包含 capabilities.logging。
  2. 客户端是否发出过 logging/setLevel,级别是多少。
  3. 服务端是否真的发出了 notifications/message,level 字段是什么。

如果第 3 步有 debug 帧而界面没有,问题在客户端展示层,不在服务端。

会话重连后级别重置

第三类缺失最隐蔽。远程 MCP Server 用 Streamable HTTP 时,会话可能因为网络抖动、客户端重启或服务端滚动发布而重建。会话级别是会话状态,会话没了,级别也就回到默认值。

假设客户端在会话 A 里设了 debug,排障中途网络断开,客户端自动重连建立会话 B。会话 B 的服务端状态是全新的,最低级别回到实现默认值,通常是 info 或更高级别。客户端如果没有在重连后重新发送 logging/setLevel debug,debug 消息会再次被过滤掉。

表现上很像“日志时有时无”:重连前能看到 debug,重连后就只剩 info。如果排障过程中反复重连,日志会断续出现,容易被误判为服务端限流或消息丢失。

工程上通常的做法是:客户端在每次会话初始化完成后,把用户期望的日志级别重新下发一次,而不是只在用户手动切换级别时发送。服务端则应在会话建立时明确一个默认级别,并在文档里写清楚,避免客户端误以为默认就是 debug。

与本地日志、轮询方案的对比

远程 MCP Server 的日志不止一种拿法。除了协议通知,还可以直接读服务端本地日志,或者让客户端轮询一个日志查询工具。三种方式在排障场景下的差异如下:

维度协议通知 notifications/message服务端本地日志轮询日志查询工具
实时性产生即推送,接近实时取决于采集与传输链路受轮询间隔限制
级别控制由客户端通过 logging/setLevel 按会话设置由服务端本地配置决定,客户端改不了由工具实现决定,通常可传参
会话关联天然绑定当前会话需要额外字段区分会话需要工具支持按会话过滤
历史回溯只覆盖当前会话在线期间可长期保留取决于日志存储
重连影响级别随会话重置,需重新下发不受影响不受影响
客户端依赖客户端必须处理通知并展示客户端不参与客户端需实现轮询逻辑
敏感信息风险推送内容进入客户端,需服务端过滤留在服务端侧,暴露面较小同协议通知,取决于返回内容

选择取决于排障目标。要看“此刻这个会话正在发生什么”,协议通知最直接,但必须处理级别同步和重连重置。要做事后审计或跨会话分析,本地日志更合适。轮询适合客户端不方便处理异步通知、或需要按条件检索历史日志的场景,代价是延迟和额外的工具调用开销。

三者可以并存。常见组合是:本地日志做长期留存,协议通知做实时排障,轮询做按需检索。

可观测指标与部署边界

要让这套机制在生产环境可用,需要观察几个信号:

  • 当前会话的最低日志级别:服务端应能按会话查询,客户端切换后确认服务端已更新。
  • 通知发送计数:按 level 和 logger 分别统计,判断是没产生还是被过滤。
  • 通知丢弃计数:因低于会话级别而未发送的数量,能直接区分“没日志”和“日志被级别挡住”。
  • 会话重建次数:频繁重建意味着级别会频繁重置,客户端需要相应处理。
  • 通知速率:规范建议服务端对日志消息限流,debug 级别尤其容易触发限流,表现为日志被丢弃而非缺失。

部署边界上有几条硬约束。规范要求日志消息不得包含凭证、密钥、个人身份信息,以及可能帮助攻击的内部系统细节。debug 级别最容易违反这条,因为调试信息常带请求参数、内部主机名和路径。服务端在推送前必须过滤,不能依赖客户端不展示。

服务端还应对日志消息限流,并校验 data 字段。客户端侧则要控制日志访问权限,并监控敏感内容。这些要求意味着:把日志级别开放给客户端按需调高,同时要接受“客户端可能收到敏感调试信息”的风险,需要服务端在发送前完成脱敏。

仍未解决的问题是默认级别和重连语义没有统一约定。规范没有规定会话建立时的默认最低级别,也没有规定重连后客户端是否必须重新下发。不同实现的行为可能不一致,跨客户端排障时需要先确认这两点。

回到那个排障现场

现在可以按顺序排查。先确认服务端本地日志里有没有 debug 行,没有就先解决本地级别。本地有而客户端没有,抓一次传输,确认客户端发过 logging/setLevel debug,且服务端发出了对应的 notifications/message。如果通知存在但界面不显示,问题在客户端展示层。如果排障中途发生过重连,检查客户端是否在重连后重新下发了级别。

“调高日志级别”这句话在 MCP 里对应两个不同的开关,把它们分开看,日志缺失就不再是玄学。

资料来源

  1. Model Context Protocol Specification - Logging
  2. MCP Python SDK - Logging Utilities
  3. 日志记录 | MCP 中文文档
  4. 日志记录