博客 • 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 改的是 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 包装。

2.4.0 期望:字段在 structuredContent 根。
{
  "structuredContent": {
    "region": "apac",
    "count": 42
  }
}
legacy:sole key result 仍可能出现。
{
  "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。

审计记录:protocol 与 metaIdTool 决定 unwrap 规则。
{
  "tool": "get_report",
  "protocol": "2025-11-25",
  "metaIdTool": true
}

用下面 schema 固定审计记录形状。metaIdTool 为 false 的普通工具仍走 validate_root,但 structuredContent 可能根本没有业务字段;那种情况由 missing 分支处理,而不是套用 legacy_wrapper。

审计记录 schema。先过形状,再选 validate_root 或 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 任务里常一起出现。

会被 fast-uri 拒绝的 $id 示例。
{
  "$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 不在 enumschema先修审计 JSON,不要对响应猜路径。
记录合法但 body 没有 structuredContentmissing按不完整响应处理,不要静默通过。

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 规则会把真阳性变成漏报。

闸门函数:schema、legacy_wrapper、validate_root、missing。
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. 1
    用 Schema 固定审计记录

    打开 JSON Schema。第一栏贴审计 schema,第二栏贴 tools/call 旁路提取的 record。protocol 或 metaIdTool 写错会先标红,闸门不应在形状错误时选路径。

  2. 2
    用格式化确认 structuredContent 层级

    打开 JSON 格式化,贴整段 tools/call 结果。肉眼看 structuredContent 是扁平根还是 sole key result,再决定输出 schema 挂在哪一层。

  3. 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。

返回博客