博客 • MCP / TypeScript SDK
升级到 MCP TypeScript SDK 2.4.0 之后,tools/call 返回的 structuredContent 要按哪一层 JSON 做 Schema 校验?
2026 年 10 月 23 日,MCP TypeScript SDK 发布 @modelcontextprotocol/server 2.4.0。在协议修订 2025-11-25 上,用 z.object().meta({ id }) 声明输出 schema 的工具,tools/call 响应里的 structuredContent 不再自动包一层 result,字段直接出现在根对象。
很多审计脚本仍对 structuredContent.result 做 Ajv,升级客户端后会把合法响应标红。旧服务器或未重启的进程仍可能返回带 result 的 legacy 形状,所以流水线不能只改路径,还要用审计记录里的 protocol 与 metaIdTool 决定走 validate_root 还是 legacy_wrapper。
MCP 连接与凭证边界写在 MCP 安全。仅有 Schema 合法仍不够,见 Schema 不够。Bedrock 侧结构化输出是另一套信封,见 Bedrock 结构化输出。本篇只写 2.4.0 之后 structuredContent 该在哪一层做 Schema,以及闸门怎么分支。
- 2.4.0 客户端在 2025-11-25 上期望 meta id 工具的 structuredContent 根对象就是输出 payload。
- 仍只校验 .result 的流水线会在升级后误报;要先读响应形状再选 schema 路径。
- legacy 服务器可能仍返回 sole key result;闸门应返回 legacy_wrapper 并对 result 内对象做 Schema。
- 输出 JSON Schema 的 $id 避免 my_tool:output 这类 URI;Ajv 3.1.8 与 fast-uri 会拒绝,请改用下划线或 $defs。
先记住这一点:2.4.0 改的是 meta id 工具在 2025-11-25 上的 structuredContent 形状,不是 tools/list 本身。下面按发布说明、unwrap 对比、审计记录、$id 陷阱、闸门返回值,以及 JSONNote 里怎么对照两层 JSON。
2.4.0 在 2025-11-25 上 unwrap 了 meta id 工具的 structuredContent
变更与迁移说明见 @modelcontextprotocol/server 2.4.0 发布。TypeScript SDK 在客户端侧不再为带 meta id 的输出 schema 自动插入 result 包装;服务器若仍按旧习惯返回 sole key result,审计必须识别 legacy_wrapper。
同一次升级还收紧了 Ajv 依赖:fast-uri 3.1.8 按 URI 规则解析 $id,像 my_tool:output 会被拒。输出 schema 请用 my_tool_output 或 $defs 引用,不要把冒号写进 $id。这与 unwrap 无关,但会在同一条校验流水线里一起爆雷。
unwrap 之后 structuredContent 长什么样?
在协议 2025-11-25 上,2.4.0 客户端期望 region、count 等业务字段直接出现在 structuredContent 根上。下面第一份样本是升级后期望的形状;第二份是旧客户端或旧服务器常见的 result 包装。
{
"structuredContent": {
"region": "apac",
"count": 42
}
}
{
"structuredContent": {
"result": {
"region": "apac",
"count": 42
}
}
}
审计时不要假设所有响应都已 unwrap。看到只有 result 且其内才是 payload 时,应对 result 内对象跑输出 schema,并在日志里标记 legacy_wrapper,方便统计还有多少服务器未重启。
审计记录要怎样描述这次 tools/call?
闸门函数需要知道 protocol、工具名、以及该工具是否用 z.object().meta({ id }) 声明输出。下面样本把 metaIdTool 设为 true,protocol 固定为 2025-11-25。缺 tool 或 protocol 写错时,应先返回 schema,不要硬选 validate_root。
{
"tool": "get_report",
"protocol": "2025-11-25",
"metaIdTool": true
}
用下面 schema 固定审计记录形状。metaIdTool 为 false 的普通工具仍走 validate_root,但 structuredContent 可能根本没有业务字段;那种情况由 missing 分支处理,而不是套用 legacy_wrapper。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["tool", "protocol", "metaIdTool"],
"properties": {
"tool": { "type": "string", "minLength": 1 },
"protocol": { "enum": ["2025-11-25", "2026-07-28"] },
"metaIdTool": { "type": "boolean" }
}
}
输出 schema 的 $id 为什么会让 Ajv 直接失败?
2.4.0 把 Ajv 升到与 fast-uri 3.1.8 组合。fast-uri 把 $id 当 URI 解析,my_tool:output 里的冒号会被当成 scheme,整串非法。改成 my_tool_output,或用 $defs 加 $ref,校验器才能加载 schema。这与 structuredContent 层无关,但和 unwrap 在同一条 CI 任务里常一起出现。
{
"$id": "my_tool:output",
"type": "object",
"additionalProperties": false,
"required": ["region"],
"properties": {
"region": { "type": "string" }
}
}
| 响应与记录 | 闸门返回 | Schema 应打在哪一层 |
|---|---|---|
| metaIdTool 且 protocol 2025-11-25,根上有 region 等字段 | validate_root | 对 structuredContent 根对象跑输出 schema。 |
| 同样记录,但 structuredContent 只有 result 一个键 | legacy_wrapper | 对 structuredContent.result 内对象跑输出 schema。 |
| 审计记录缺 tool 或 protocol 不在 enum | schema | 先修审计 JSON,不要对响应猜路径。 |
| 记录合法但 body 没有 structuredContent | missing | 按不完整响应处理,不要静默通过。 |
structured_content_target 怎样在四条分支间选择?
实现顺序:先用审计 schema 校验 record;再读 body.structuredContent。对 metaIdTool 且 protocol 2025-11-25,若 content 只有 result 一个键且值为对象,返回 legacy_wrapper;否则返回 validate_root。metaIdTool 为 false 时始终 validate_root。content 不是对象则 missing。
不要把 legacy_wrapper 和 validate_root 合成一个布尔 passed。报表里分开计数,才能看到升级后还有多少调用仍走 result 包装。plain 工具误用 legacy 规则会把真阳性变成漏报。
def actual_type(data: object) -> str:
if data is None:
return "null"
if isinstance(data, bool):
return "boolean"
if isinstance(data, list):
return "array"
if isinstance(data, int):
return "integer"
if isinstance(data, float):
return "number"
if isinstance(data, str):
return "string"
if isinstance(data, dict):
return "object"
raise TypeError(type(data))
def validate(data: object, schema: dict) -> list[str]:
errors: list[str] = []
if not isinstance(schema, dict):
return errors
types = schema.get("type")
if isinstance(types, str):
types = [types]
actual = actual_type(data)
if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
errors.append("type")
if "enum" in schema and not any(value == data for value in schema["enum"]):
errors.append("enum")
if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
errors.append("minLength")
if isinstance(data, dict) and schema.get("properties"):
for key in schema.get("required") or []:
if key not in data:
errors.append(f"required:{key}")
if schema.get("additionalProperties") is False:
for key in data:
if key not in schema["properties"]:
errors.append(f"additional:{key}")
for key, sub in schema["properties"].items():
if key in data and data[key] is not None:
errors.extend(validate(data[key], sub))
return errors
def structured_content_target(record: dict, body: dict, schema: dict) -> str:
if validate(record, schema):
return "schema"
content = body.get("structuredContent")
if not isinstance(content, dict):
return "missing"
if record.get("metaIdTool") and record.get("protocol") == "2025-11-25":
if set(content.keys()) == {"result"} and isinstance(content.get("result"), dict):
return "legacy_wrapper"
return "validate_root"
return "validate_root"
在 JSONNote 里怎样对照根对象与 result?
- 1用 Schema 固定审计记录
打开 JSON Schema。第一栏贴审计 schema,第二栏贴 tools/call 旁路提取的 record。protocol 或 metaIdTool 写错会先标红,闸门不应在形状错误时选路径。
- 2用格式化确认 structuredContent 层级
打开 JSON 格式化,贴整段 tools/call 结果。肉眼看 structuredContent 是扁平根还是 sole key result,再决定输出 schema 挂在哪一层。
- 3用 Diff 对比 unwrap 前后
打开 JSON Diff。左边放 2.4.0 期望根对象,右边放 legacy result 包装。Diff 高亮的键路径就是审计脚本最该改的一行。
常见问题
协议 2026-07-28 也会 unwrap structuredContent 吗?
就 meta id 工具的 result 包装而言,2.4.0 的 unwrap 行为绑定在 2025-11-25 上,2026-07-28 并未再改这一层。换 protocol 修订时仍要读 SDK 发布说明,但不要把 2026-07-28 当成会自动再 unwrap 一次。
2026-07-28 客户端不再列出 number 工具上的 x-mcp-header,会影响校验吗?
不影响 structuredContent 该打在哪一层。那是 tools/list 展示层的防御性收敛:number 类型工具不再把 x-mcp-header 暴露给客户端枚举。审计仍应对 tools/call 响应体做 schema,不要靠 list 里是否出现某个 annotation 推断 unwrap。
metaIdTool 为 false 时还要走 legacy_wrapper 吗?
不要。legacy_wrapper 只针对带 meta id 输出声明且 protocol 为 2025-11-25 的工具。普通工具始终 validate_root;若 structuredContent 缺失或不是对象,返回 missing。
能否永远校验 structuredContent.result 以兼容新旧?
不能。2.4.0 在 2025-11-25 上把字段放在根上,只查 result 会把合法响应判失败,也会漏掉 result 键已消失的情况。应用闸门分支,或在 legacy_wrapper 时临时取 content.result。
Ajv 报 $id URI 非法时该改响应还是改 schema?
改输出 schema 文件,不是改 tools/call 响应。把 $id 里的冒号换成下划线,或改用 $defs 内部引用。响应体是否 unwrap 与 $id 能否被 fast-uri 解析是两件事,但在同一条 CI 里常连续失败。
总结
2.4.0 把 meta id 工具在 2025-11-25 上的 structuredContent 形状改成了根对象直出,但 legacy 服务器仍会返回 result 包装。审计流水线必须分支,而不是把 .result 路径写死。
直接结论:protocol 2025-11-25 且 metaIdTool 为 true 时,structuredContent 根上有业务字段就走 validate_root;sole key result 走 legacy_wrapper 并校验 result 内对象;审计记录不过 schema 先返回 schema;body 无 structuredContent 返回 missing;输出 schema 的 $id 勿用冒号。
下一步:用 Schema 固定审计记录,用格式化看清 structuredContent 层级,用 Diff 对比 unwrap 前后,并修正输出 schema 的 $id。