Blog • MCP / TypeScript SDK

After upgrading to MCP TypeScript SDK 2.4.0, which JSON layer should Schema validation use on tools/call structuredContent?

On October 23, 2026 the MCP TypeScript SDK shipped @modelcontextprotocol/server 2.4.0. On protocol revision 2025-11-25, tools that declare output with z.object().meta({ id }) return structuredContent whose fields sit at the root instead of an automatic result wrapper.

Many audit scripts still run Ajv on structuredContent.result; after upgrading the client, valid responses turn red. Older servers or processes that were not restarted may still return the legacy result wrapper, so the pipeline must use protocol and metaIdTool on the audit record to pick validate_root or legacy_wrapper.

MCP connection and credential boundaries are in MCP security. Schema-valid alone is not enough, see schema not enough. Bedrock structured output is a different envelope, in Bedrock structured output. This article covers only which layer gets Schema after 2.4.0 and how the gate branches.

Keep this in mind: 2.4.0 changes the structuredContent shape for meta id tools on 2025-11-25, not tools/list itself. Below: release notes, unwrap comparison, audit record, $id pitfalls, gate return values, and how JSONNote compares the two JSON layers.

2.4.0 unwraps structuredContent for meta id tools on 2025-11-25

The change and migration notes are in the @modelcontextprotocol/server 2.4.0 release. The TypeScript SDK no longer auto-inserts a result wrapper on the client for output schemas with a meta id; if the server still returns a sole key result, audits must recognize legacy_wrapper.

The same release tightens Ajv dependencies: fast-uri 3.1.8 parses $id as a URI and rejects values like my_tool:output. Use my_tool_output or $defs references instead of colons in $id. Unrelated to unwrap, but it fails in the same validation pipeline.

What does structuredContent look like after unwrap?

On protocol 2025-11-25, 2.4.0 clients expect business fields like region and count on the structuredContent root. The first sample is the post-upgrade shape; the second is the legacy result wrapper common on older clients or servers.

Expected in 2.4.0: fields on the structuredContent root.
{
  "structuredContent": {
    "region": "apac",
    "count": 42
  }
}
Legacy: a sole key result may still appear.
{
  "structuredContent": {
    "result": {
      "region": "apac",
      "count": 42
    }
  }
}

Do not assume every response is unwrapped. When the only key is result and the payload sits inside, run the output schema on the inner object and log legacy_wrapper to track servers not yet restarted.

How should the audit record describe this tools/call?

The gate needs protocol, tool name, and whether the tool declared output with z.object().meta({ id }). The sample sets metaIdTool true and protocol to 2025-11-25. Missing tool or a wrong protocol should return schema first, not force validate_root.

Audit record: protocol and metaIdTool pick the unwrap rule.
{
  "tool": "get_report",
  "protocol": "2025-11-25",
  "metaIdTool": true
}

Pin the audit record with the schema below. Plain tools with metaIdTool false still use validate_root, but structuredContent may lack business fields; missing handles that, not legacy_wrapper.

Audit record schema. Pass shape first, then pick validate_root or 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" }
  }
}

Why does $id on the output schema make Ajv fail immediately?

2.4.0 bumps Ajv to work with fast-uri 3.1.8. fast-uri parses $id as a URI; the colon in my_tool:output becomes a scheme and the string is invalid. Use my_tool_output or $defs with $ref so the validator loads the schema. Unrelated to the structuredContent layer but often hits the same CI job as unwrap.

Sample $id that fast-uri rejects.
{
  "$id": "my_tool:output",
  "type": "object",
  "additionalProperties": false,
  "required": ["region"],
  "properties": {
    "region": { "type": "string" }
  }
}
Response and recordGate returnsWhich layer gets Schema
metaIdTool and protocol 2025-11-25, region and peers on the rootvalidate_rootRun the output schema on the structuredContent root object.
Same record, but structuredContent has only key resultlegacy_wrapperRun the output schema on the object inside structuredContent.result.
Audit record missing tool or protocol not in enumschemaFix the audit JSON first; do not guess the response path.
Valid record but body lacks structuredContentmissingTreat as an incomplete response; do not pass silently.

How does structured_content_target pick among four branches?

Order: validate record with the audit schema; read body.structuredContent. For metaIdTool and protocol 2025-11-25, if content has only key result and it is an object, return legacy_wrapper; else validate_root. metaIdTool false always validate_root. Non-object content → missing.

Do not collapse legacy_wrapper and validate_root into one boolean passed. Count them separately in reports to see how many calls still use the result wrapper after upgrade. Applying legacy rules to plain tools turns true positives into misses.

Gate function: 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"

How do you compare root versus result in JSONNote?

  1. 1
    Pin the audit record with Schema

    Open JSON Schema. Paste the audit schema in the first field and the sidecar record from tools/call in the second. Wrong protocol or metaIdTool flags first; the gate must not pick a path on a bad shape.

  2. 2
    Use Format to confirm the structuredContent layer

    Open JSON Format, paste the full tools/call result. Eyeball whether structuredContent is a flat root or sole key result before attaching the output schema.

  3. 3
    Diff unwrapped versus wrapped samples

    Open JSON Diff. Put the 2.4.0 expected root on the left and legacy result on the right. The highlighted key path is the line your audit script must change.

Common questions

Does protocol 2026-07-28 also unwrap structuredContent?

For the meta id result wrapper, 2.4.0 unwrap behavior is tied to 2025-11-25; 2026-07-28 did not change this layer again. Read SDK release notes when switching protocol revision, but do not expect 2026-07-28 to unwrap again automatically.

2026-07-28 clients drop x-mcp-header on number tools from the list—does that affect validation?

It does not change which layer gets structuredContent Schema. That is a defensive tools/list presentation change: number tools no longer expose x-mcp-header to client enumeration. Audits should still schema the tools/call body, not infer unwrap from whether an annotation appears in list.

Should metaIdTool false still use legacy_wrapper?

No. legacy_wrapper is only for tools with meta id output on protocol 2025-11-25. Plain tools always validate_root; missing applies when structuredContent is absent or not an object.

Can we always validate structuredContent.result for compatibility?

No. On 2025-11-25, 2.4.0 puts fields on the root; validating only result fails valid responses and misses cases where the result key is gone. Use gate branches or read content.result only when legacy_wrapper.

When Ajv reports an invalid $id URI, fix the response or the schema?

Fix the output schema file, not the tools/call response. Replace colons in $id with underscores or use internal $defs references. Unwrap in the response and whether fast-uri parses $id are separate, but both often fail in one CI job.

Summary

2.4.0 moves meta id tools on 2025-11-25 to a flat structuredContent root, yet legacy servers may still return a result wrapper. Audit pipelines must branch instead of hard-coding a .result path.

Decision: on protocol 2025-11-25 with metaIdTool true, business fields on the structuredContent root → validate_root; sole key result → legacy_wrapper and validate inside result; audit record failing schema → schema; no structuredContent → missing; no colons in output schema $id.

Next: pin the audit record with Schema, use Format to see the structuredContent layer, Diff unwrapped versus wrapped, and fix output schema $id.

Back to the blog