ブログ • 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 }) で出力を宣言したツールの 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 形であり、tools/list 自体ではない。以下はリリース、unwrap 比較、監査レコード、$id の落とし穴、ゲート戻り値、JSONNote での二層 JSON 対照。

2.4.0 は 2025-11-25 で meta id ツールの structuredContent を unwrap

変更と移行は @modelcontextprotocol/server 2.4.0 リリース。TypeScript SDK は meta id 付き出力 schema に result 包装を自動挿入しない。サーバが result 単一キーを返すなら legacy_wrapper を識別する。

同じリリースで Ajv 依存が厳しくなり、fast-uri 3.1.8 は $id を URI として my_tool:output を拒否する。$id にコロンを書かず my_tool_output か $defs を使う。unwrap とは別だが同じ検証流水线で落ちる。

unwrap 後の structuredContent は?

プロトコル 2025-11-25 では 2.4.0 クライアントは region や count を structuredContent 根に期待する。下の第一が更新後、第二が legacy の result 包装。

2.4.0 期待:根にフィールド。
{
  "structuredContent": {
    "region": "apac",
    "count": 42
  }
}
legacy:result 単一キーが残りうる。
{
  "structuredContent": {
    "result": {
      "region": "apac",
      "count": 42
    }
  }
}

すべて unwrap 済みと決めつけない。result だけで中身が payload なら、中で出力 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 に直す。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_rootstructuredContent 根に出力 schema。
同じ記録だが structuredContent は result のみlegacy_wrapperstructuredContent.result 内に出力 schema。
監査で tool 欠落か protocol が enum 外schema監査 JSON を直してから。応答パスを当てない。
記録は合法だが body に structuredContent なしmissing不完全応答として扱い、黙って通さない。

structured_content_target は四分支をどう選ぶか?

順序:監査 schema で record を検証し、body.structuredContent を読む。metaIdTool かつ 2025-11-25 で content が result 単一キーなら legacy_wrapper、それ以外は validate_root。metaIdTool false は常に validate_root。content が object でなければ 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。1欄に監査 schema、2欄に tools/call から抜いた record。protocol や metaIdTool の誤りが先に赤くなり、形が壊れた時点でパスを選ばない。

  2. 2
    フォーマットで structuredContent 層を確認

    開く JSON フォーマット。tools/call 結果全体を貼り、structuredContent が扁平根か result 単一キーかを見て schema 層を決める。

  3. 3
    Diff で unwrap 前後を比較

    開く JSON Diff。左に 2.4.0 期待根、右に legacy result。Diff で光るキー路径が監査脚本の修正行。

よくある質問

プロトコル 2026-07-28 も structuredContent を unwrap するか?

meta id の result 包装について、2.4.0 の unwrap は 2025-11-25 に紐づき、2026-07-28 はこの層を再変更していない。改訂を変えるときは SDK リリースを読むが、2026-07-28 が再 unwrap するとは限らない。

2026-07-28 クライアントが number ツールの x-mcp-header を一覧から外すのは検証に効くか?

structuredContent の Schema 層は変わらない。tools/list 表示の防御的変更で、number ツールは x-mcp-header を列挙に出さない。監査は tools/call 本体を schema し、list の annotation から unwrap を推測しない。

metaIdTool false でも legacy_wrapper か?

いいえ。legacy_wrapper は meta id 出力かつ 2025-11-25 のツールだけ。通常ツールは常に validate_root。structuredContent が無いか object でなければ missing。

新旧兼容のため常に structuredContent.result を検証できるか?

できない。2025-11-25 では 2.4.0 が根に置くので result だけだと合法が失敗し、result キー消失も見逃す。ゲート分岐を使うか legacy_wrapper 時だけ content.result を読む。

Ajv が $id URI 非法と言ったら応答か schema か?

直すのは出力 schema であり tools/call 応答ではない。$id のコロンをアンダースコアにするか $defs 参照にする。応答の unwrap と $id 解析は別問題だが同一 CI で連続して落ちる。

まとめ

2.4.0 は 2025-11-25 の meta id ツールを structuredContent 根直出しにしたが、legacy サーバは result 包装を返しうる。監査は .result 固定ではなく分岐が要る。

結論:protocol 2025-11-25 かつ metaIdTool true で根に業務フィールドなら validate_root。result 単一キーなら legacy_wrapper で result 内を検証。監査が schema 不通なら schema。structuredContent 無しは missing。出力 $id にコロンを書かない。

次:Schema で監査を固定し、フォーマットで structuredContent 層を見て、Diff で unwrap 前後を比べ、出力 $id を直す。

ブログへ戻る