一个看似矛盾的排障现场
线上有一个远程 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 以上。
排查时不要只看界面。在传输层抓一次会话,确认三件事:
- 服务端初始化响应里是否包含
capabilities.logging。 - 客户端是否发出过
logging/setLevel,级别是多少。 - 服务端是否真的发出了
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 里对应两个不同的开关,把它们分开看,日志缺失就不再是玄学。