AI 技术
#MCP#JSON Schema#工具调用#输出校验#协议实现

MCP 工具输出校验:structuredContent 与 outputSchema 不一致时客户端为何解析失败

远程 MCP Server 返回结构化工具结果时,outputSchema 声明返回结构,structuredContent 承载实际负载。本文用一个订单查询工具贯穿全文,分析二者不一致、schema 未声明、客户端只读 content 文本分支时各自的解析失败路径,对比纯文本封装与严格校验两种方案,并给出服务端与客户端的校验点和可观测指标。

一个订单查询工具在客户端抛出的异常

设想一个企业内部助手,它通过远程 MCP Server 暴露一个 query_order 工具。服务端在 tools/list 里声明了 outputSchema,字段包括 orderId、status、amount、currency。某天运维把金额字段的类型从字符串改成数字,客户端代码里对应的解析路径没有同步修改,于是模型拿到工具结果后开始报错,或者更糟:它安静地把金额当成 null 处理,给出了错误的对账结论。

这类故障的根源不在模型,而在工具结果的传输契约。MCP 把工具结果拆成两个字段:content 面向模型阅读,structuredContent 面向程序解析。规范给 outputSchema 的定位是校验 structuredContent 的结构。三者只要有一处对不上,客户端就可能解析失败,或者看似成功却拿到错误数据。

下面用这个订单查询工具贯穿全文。它足够小,可以完整写出请求和响应;又足够真实,覆盖类型漂移、字段缺失、多分支读取这些常见问题。

三个字段各自的职责

outputSchema 声明的是结构化结果的形状

工具定义里的 outputSchema 是一个 JSON Schema,描述这个工具返回的结构化结果应该长什么样。它和 inputSchema 对称:inputSchema 约束调用方传入的参数,outputSchema 约束服务端返回的结构化负载。

JSON Schema 本身是一套描述和校验 JSON 文档的词汇表。type 限定数据类型,properties 列出对象字段,required 指定必填字段,enum 限定取值集合。这些关键字组合起来,就能表达“amount 必须是数字、currency 必须是三个大写字母”这类约束。

MCP 规范对 outputSchema 的约束是明确的:如果工具提供了输出 schema,服务端必须返回符合该 schema 的结构化结果,客户端应当用该 schema 校验结构化结果。注意这里的措辞差异,服务端是 MUST,客户端是 SHOULD。这解释了为什么很多解析失败发生在客户端:服务端没有强制自检,客户端又选择跳过校验。

structuredContent 承载机器可读的负载

structuredContent 是 CallToolResult 里的一个 JSON 对象字段,存放服务端产出的结构化结果。规范特别澄清了一点:这里的“结构化”和语言模型的“结构化输出”(用 schema 约束模型生成)没有关系,它只是服务端返回的数据。

订单查询工具返回的 structuredContent 大致是这样:

{
  "orderId": "A-10086",
  "status": "paid",
  "amount": 199.00,
  "currency": "CNY"
}

客户端可以直接把它反序列化成类型化对象,用于后续编排、代码生成或类型安全的流程控制。

content 面向模型,不保证结构

content 是一个内容块数组,可以放文本、图片、音频、资源链接和嵌入资源。对于文本结果,最常见的形式是一个 text 块。规范给出的向后兼容建议是:返回结构化内容的工具,应当同时在 TextContent 块里返回序列化后的 JSON。

这个建议的初衷是让不支持 structuredContent 的老客户端仍能读到数据。但它同时埋下了一个隐患:content 里的文本是给人和模型看的,规范并不要求它和 structuredContent 逐字段一致。

不一致的四种典型形态

类型漂移

服务端把 amount 从字符串 "199.00" 改成数字 199.00,但 outputSchema 仍写着 "type": "string"。客户端如果先按 schema 校验,会直接判定结果非法;如果跳过校验直接取值,amount 在期望字符串的代码路径里可能变成 null 或抛类型错误。

字段缺失或改名

服务端升级后把 status 改成 orderStatus,outputSchema 没有同步。客户端按旧 schema 校验时 required 里的 status 缺失,校验失败。这类问题在灰度发布期间尤其常见:新旧两个版本的服务端同时在线,客户端拿到的结果结构不稳定。

语义重复而非等价

规范要求两个字段同时存在时语义等价,只是呈现方式不同。实际实现里经常出现两种偏差。一种是 content 里塞了完整 JSON 字符串,structuredContent 里又是同一份对象,模型和程序各读一份,上下文被重复占用。另一种是 content 写“查询成功”,structuredContent 里才有真实数据,模型只读 content 时会以为任务已经完成,却拿不到订单号。

schema 未声明

工具根本没有提供 outputSchema,但服务端仍然返回了 structuredContent。这时客户端没有可用的校验依据。规范允许工具不声明输出 schema,此时 structuredContent 的结构完全由服务端自行决定。客户端如果假设某个字段一定存在,就会在字段缺失时失败。

客户端只读 content 时会发生什么

不同客户端对这两个字段的处理策略并不统一。有的客户端优先用 content 作为模型输入,有的优先用 structuredContent,有的两个都转发,还有的直接忽略 structuredContent。这种分歧不是实现者的疏忽,而是规范早期对两个字段的定位不够清晰造成的。

对订单查询工具来说,如果客户端只读 content 文本分支,会出现三类结果。

第一类是文本里根本没有结构化数据。服务端只返回了 structuredContent,content 是一个空数组或一句“查询完成”。客户端把这句话交给模型,模型无法得知订单金额,只能编造或追问。

第二类是文本里是 JSON 字符串,但客户端不做解析。模型看到的是 {"orderId":"A-10086",...} 这段原始文本。它可能理解,也可能在长上下文里被截断,导致字段丢失。

第三类是文本和结构化数据不一致。content 里写的是缓存中的旧金额,structuredContent 里是新金额。只读 content 的客户端会拿到过期数据,而且没有任何机制能发现这一点。

校验应该发生在哪些位置

下图展示一次 tools/call 从客户端发出到结果被消费的完整路径,以及三个校验点的位置。

flowchart TD
    A[客户端发起 tools/call] --> B[服务端执行业务逻辑]
    B --> C[构造 structuredContent]
    C --> D{校验点 1 服务端自检}
    D -->|不符合 outputSchema| E[返回 isError 或修正数据]
    D -->|符合| F[序列化写入 content 文本块]
    F --> G[返回 CallToolResult]
    G --> H{校验点 2 客户端校验}
    H -->|不符合 outputSchema| I[拒绝结果并记录指标]
    H -->|符合| J{校验点 3 消费分支}
    J -->|程序解析| K[读取 structuredContent]
    J -->|模型阅读| L[读取 content 文本]
    K --> M[类型化对象]
    L --> N[模型上下文]

校验点 1 在服务端。服务端在返回前用 outputSchema 校验自己构造的 structuredContent。这是成本最低的位置:数据还在服务端内存里,可以修正、重试或直接返回错误,不必让客户端拿到一个坏结果。

校验点 2 在客户端收到响应之后。客户端用工具定义里的 outputSchema 校验 structuredContent。这一步能拦住服务端自检遗漏的情况,也能发现服务端版本和客户端缓存的工具定义不一致的问题。

校验点 3 在消费分支。程序解析路径读 structuredContent,模型阅读路径读 content。两条路径对数据完整性的要求不同,需要分别处理。

纯文本封装与严格校验的对比

两种方案都能让工具结果到达客户端,但代价和适用范围差别很大。

维度纯文本封装严格校验
结果载体只用 content 文本块structuredContent 加 outputSchema
客户端解析靠模型理解或正则提取反序列化为类型化对象
类型安全无,字段类型随时可变由 schema 约束,类型漂移可被发现
向后兼容老客户端天然可用需要同时返回 content 文本块
模型上下文成本较低,文本可精简较高,结构化数据通常更啰嗦
失败可见性低,错误数据混在文本里高,校验失败可计数告警
实现复杂度低,服务端只拼字符串中,需要维护 schema 与校验逻辑
适用场景对话式助手、只读查询代码生成、工具编排、类型安全流程

纯文本封装的优势在于简单和兼容。服务端不需要维护 schema,客户端不需要引入校验库,模型直接读文本。代价是类型安全完全缺失,字段改名、类型漂移、语义不一致都无法在传输层被发现。

严格校验的优势在于失败可见。schema 一旦声明,服务端自检和客户端校验都能把不一致变成可计数的错误,而不是悄悄传播的脏数据。代价是服务端要维护 schema,客户端要引入校验逻辑,并且要处理向后兼容的文本分支。

规范给出的折中方案是:返回结构化内容的工具应当同时返回序列化 JSON 的文本块。这样两类客户端都能工作,但服务端必须保证两份数据语义等价。

可观测指标与失败排查

校验逻辑上线后,需要能回答“现在有多少结果没通过校验”和“失败集中在哪个字段”。

服务端可以采集这些指标:outputSchema 校验失败次数,按工具名和字段路径分组;structuredContent 与 content 文本块语义不一致的次数;返回 isError 的工具调用占比;单个工具的 schema 版本分布。

客户端可以采集:收到的 structuredContent 缺失但 outputSchema 已声明的次数;校验失败后降级到 content 文本分支的次数;按工具名统计的解析异常率;模型因结果不可用而重试或追问的比例。

排查时按这个顺序缩小范围。先确认工具定义里的 outputSchema 版本,再看服务端实际返回的 structuredContent 是否符合该版本。如果两者对不上,问题在服务端发布流程,可能是 schema 和实现没有一起更新。如果两者一致但客户端仍失败,检查客户端缓存的工具定义是否过期。如果只在部分请求上失败,检查是否有灰度版本的服务端在返回旧结构。

一个容易被忽略的信号是 content 文本块和 structuredContent 的字段差异。当服务端只更新了其中一份,另一份会保留旧值。把两份数据做字段级对比并记录差异,能在用户察觉之前发现这类问题。

什么时候这套校验会失效

严格校验不是万能的,它的有效性依赖几个前提。

工具必须声明 outputSchema。没有声明时,客户端没有校验依据,只能信任服务端返回的结构。规范允许这种情况,所以客户端不能假设所有工具都有输出 schema。

schema 必须和实现同步更新。如果服务端改了返回结构却没有改 schema,服务端自检会失败,但客户端如果跳过校验,仍会拿到不符合声明的数据。schema 和实现的版本绑定需要在发布流程里保证。

客户端必须真的执行校验。规范对客户端的要求是 SHOULD 而非 MUST,很多客户端为了性能或简化实现会跳过这一步。跳过校验的客户端在服务端出错时没有任何防线。

content 文本块和 structuredContent 必须语义等价。如果服务端只保证其中一份正确,只读另一份的客户端就会拿到错误数据。这一点在规范里是 MUST,但实现中经常被忽略。

还有一个边界:structuredContent 是服务端产出的结果数据,和模型的结构化输出无关。不要把它当成约束模型生成的手段,也不要期望它能防止模型在后续推理中误用数据。它只保证传输层的数据形状。

落地时的选择

如果工具只服务于对话式助手,结果以自然语言呈现,纯文本封装足够,额外引入 schema 只会增加维护成本。

如果工具结果要被程序消费,用于代码生成、工具编排或类型安全的流程控制,就应当声明 outputSchema,在服务端返回前自检,在客户端收到后校验,并同时返回语义等价的文本块以兼容老客户端。

两者之间的取舍取决于谁消费结果。模型读文本,程序读结构。把这两个消费者混在一起处理,正是不一致问题反复出现的根源。

资料来源

  1. Model Context Protocol Specification (2025-06-18) - Tools
  2. Model Context Protocol Specification (2025-06-18) - Schema Reference
  3. JSON Schema - Understanding JSON Schema
  4. SEP-1624: Clarify `structuredContent` vs `content` Usage Guidance