一个只显示状态行的文件读取结果
假设你维护一个接远程 MCP Server 的桌面客户端。用户让模型读取仓库里的一个配置文件,服务端返回成功,界面却只显示一行“successfully downloaded text file (SHA: …)”,文件正文不见了。模型拿不到正文,后续回答只能靠猜。
这类现象在接官方 GitHub MCP Server 的 get_file_contents 工具时出现过。资料 3 记录的现象是:客户端把 resource 块硬编码替换成 [resource: content discarded] 占位符,正文放在 resource.text 里,从未进入模型上下文。同一讨论还提到,其他客户端也踩过同类问题,修法往往只是把 resource.text 拼回文本数组。
客户端只读 text 分支时,服务端返回 image 或 resource 块就会触发这条路径。本文用一个贯穿场景说明机制:远程 MCP Server 的 get_file_contents 工具读取一个 Markdown 文件,同时返回文件正文、一张截图和一条资源链接。我们跟随这次 tools/call 的响应,看客户端在哪一步把内容丢掉。
content 数组的协议形状与类型清单
MCP 规范把工具结果定义在 CallToolResult 里。资料 1 的示例显示,结果对象包含 content 数组和 isError 字段,content 可以装多个不同类型的条目。资料 2 的 Schema Reference 列出了 ContentBlock 的成员类型:TextContent、ImageContent、AudioContent、ResourceLink、EmbeddedResource。
每种块都有 type 字段作为判别标签。TextContent 带 text;ImageContent 带 base64 编码的 data 和 mimeType;AudioContent 结构相同,只是 MIME 类型指向音频;ResourceLink 带 uri、name、可选的 description 和 mimeType,它指向一个客户端可以再去订阅或读取的资源;EmbeddedResource 把资源内容直接内联在 resource 字段里,该字段可能是 TextResourceContents(带 text),也可能是 BlobResourceContents(带 blob)。
规范还说明,所有内容类型都支持可选的 annotations,用来标注受众、优先级和修改时间。audience 取值为 user 或 assistant,priority 是 0 到 1 之间的数。这些标注影响客户端如何呈现内容,不影响块本身的解析。
这里有一个容易忽略的细节:resource_link 和 resource 是两种不同的块。前者只给 URI 和元数据,正文需要客户端另行获取;后者已经把正文或二进制数据带在响应里。资料 1 明确写道,工具返回的资源链接不保证出现在 resources/list 的结果中。客户端如果先查列表再解析链接,很可能查不到。
一次 get_file_contents 调用的数据流
把场景固定下来。用户要求模型读取 docs/guide.md,并附上仓库里的一张架构图。服务端工具实现决定返回三个块:
- 一个
text块,内容是文件的 Markdown 正文; - 一个
image块,mimeType为image/png,data是截图 base64; - 一个
resource_link块,指向file:///repo/docs/guide.md,供客户端按需重新拉取。
响应经 JSON-RPC 回到客户端后,客户端要依次完成解析、判别、提取和渲染。下面的流程图画出这条路径,以及只处理 text 的客户端在哪一步偏离。
flowchart TD
A[服务端组装 CallToolResult] --> B[content 数组含 text image resource_link]
B --> C[客户端 JSON-RPC 解析]
C --> D{按 type 判别分支}
D -->|text| E[取出 text 字段]
D -->|image| F[校验 mimeType 并解码 base64]
D -->|resource_link| G[记录 URI 供后续读取]
D -->|未知类型| H[按占位或报错处理]
E --> I[拼装模型上下文]
F --> I
G --> J[按需发起 resources/read]
J --> I
H --> K[结果缺失或抛错]
关键转折在判别节点。只读 text 的客户端把 D 简化成“取 text 字段”,于是 image 和 resource_link 两个块没有对应分支。它们要么被静默跳过,要么进入一个默认分支被替换成占位字符串。资料 3 的代码片段正是后者:case 'resource' 和 case 'resource_link' 都执行 parts.push('[resource: content discarded]')。
客户端丢弃内容的三种机制
类型字段缺失导致判别落空
资料 3 展示的类型定义只有 type、text、mimeType 三个可选字段,没有给 resource 建模。解析器读到 {"type":"resource","resource":{...}} 时,resource 字段在类型上不存在,运行时被丢弃。后续代码即使想读 block.resource.text,也拿不到值。
这类失败的表现是“没有报错,但内容为空”。JSON 解析本身成功,content 数组长度也对,只是每个块的可用字段被裁剪。排查时容易误判为服务端没返回正文。
默认分支把未知类型折叠成占位符
另一种实现用 switch 处理已知类型,default 分支统一写入占位文本。资料 3 的修复建议保留了 resource_link 的占位符,理由是它本来就没有内联内容;对 resource 则改为判断 resource.text 是否为字符串,是则内联,否则继续占位。
占位符策略在日志里看起来正常,对模型却是信息损失。模型看到 [resource: content discarded] 会认为内容不可用,可能转而编造文件内容。这属于静默降级,比抛错更难发现。
MIME 校验失败触发异常
image 块带 mimeType,客户端若要渲染,通常先校验类型是否在白名单内。服务端返回 image/svg+xml 或自定义类型,而客户端只接受 image/png 和 image/jpeg,校验就会失败。失败后的处理因实现而异:有的抛异常中断整个结果处理,有的跳过该块。
抛异常的影响范围更大。一个块校验失败会让同一响应里的 text 块也一起丢失,因为异常打断了数组遍历。工程上通常把单块失败隔离,记录错误后继续处理后续块。
纯文本封装与多类型渲染的取舍
服务端可以绕开类型问题:把所有内容序列化成一段文本,塞进单个 text 块。图片转成 base64 字符串或链接,资源正文直接拼进文本。客户端只认 text,天然兼容。
这种做法牺牲了结构。客户端无法区分正文和图片,也无法按 annotations 的 audience 决定哪些内容给用户看、哪些给模型看。大图片的 base64 会撑大上下文,而模型未必需要像素数据。
多类型渲染保留结构,代价是客户端要实现完整的分支处理和降级策略。下表对比两条路线在几个工程维度上的差异,结论为定性判断。
| 维度 | 纯文本封装 | 多类型渲染 |
|---|---|---|
| 客户端兼容性 | 只处理 text 的客户端可直接使用 | 需要按 type 分支,未覆盖类型会丢失 |
| 上下文体积 | 图片 base64 与正文混在一起,难以裁剪 | 可按块裁剪,图片可走独立通道 |
| 受众控制 | 无法使用 annotations.audience | 可按 user 或 assistant 分流 |
| 资源复用 | 正文重复传输 | resource_link 可按需再读 |
| 实现复杂度 | 低,服务端一次序列化 | 高,需处理 MIME 校验与降级 |
| 失败可见性 | 内容缺失表现为文本异常 | 未处理类型可显式计数与告警 |
选择取决于客户端是否可控。自研客户端且能保证版本同步时,多类型渲染更合适。面向未知客户端分发的公共 Server,纯文本封装加结构化字段是更稳的默认值。资料 1 提到,返回结构化内容的工具应当同时把序列化 JSON 放进 TextContent 块,以保持向后兼容。这条建议同样适用于多类型结果:给只读 text 的客户端留一条可用的文本路径。
校验点与可观测指标
客户端侧的校验顺序
解析完 content 数组后,按以下顺序检查每个块:
type是否存在且为字符串;type是否在客户端支持的类型集合内;- 该类型要求的必填字段是否齐全,例如
image的data与mimeType; mimeType是否通过白名单校验;- base64 解码是否成功,解码后大小是否超过阈值。
任何一步失败都应记录块索引和 type,然后继续处理下一个块。只有全部块处理完,才决定整体结果是否可用。
服务端侧的返回约束
服务端在组装结果时应保证:resource 块的 resource 字段存在,且 text 与 blob 至少有一个;resource_link 的 uri 可被客户端解析;image 的 mimeType 与实际编码一致。资料 1 指出,使用内嵌资源的服务端应当实现 resources 能力,客户端可以据此判断是否支持该块。
需要观察的信号
生产环境中,以下指标能区分“服务端没返回”和“客户端丢了”:
- 按
type统计的块数量,对比服务端日志与客户端解析结果; - 占位符出现次数,例如匹配
content discarded的日志行; - MIME 校验失败次数,按
mimeType分组; - 单次响应中块总数与成功进入模型上下文的块数之比;
- 工具调用后模型回答中出现“内容不可用”类表述的比例。
前两项能直接定位资料 3 描述的那类硬编码丢弃。第三项用于发现白名单过窄。最后一项是端到端信号,反映内容损失是否影响了回答质量。
何时会退化以及尚未解决的问题
多类型方案在几种条件下会退化。客户端与服务端版本不同步,客户端不认识新增类型,会走默认分支。服务端返回超大 blob,客户端解码时内存上涨,可能触发超时。resource_link 指向的资源在客户端读取前已被删除,后续 resources/read 失败,正文仍然拿不到。
资料 3 的讨论里还有一条替代路径:通过中间层保留 resource 文本、结构化内容和图片块,不改官方桥接层。这说明修复可以发生在客户端、中间层或服务端任一层,选择取决于哪一层可控。
尚未解决的问题是类型集合的演进。规范新增内容类型时,旧客户端如何区分“不支持所以跳过”和“解析失败所以报错”,目前依赖实现约定。给每个块加版本或能力标记是一种思路,但资料未提供相关规范支持,这里只能作为待验证的方向。
回到开头的场景:客户端要拿到文件正文,最直接的动作是检查 resource 分支是否真的读取了 resource.text,而不是只看 type 就写占位符。服务端则应在返回多类型结果时,同时提供一条纯文本路径,让只读 text 的客户端至少不丢正文。