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