博客 • 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。

返回博客