Skip to content

structuredContent for a TypedDict return injects nulls for NotRequired keys, violating the tool's own outputSchema #3224

Description

@sainikhiljuluri

Release line

2.x (main), reproduced at a4f4ccd0.

Bug description

A tool whose return annotation is a TypedDict with NotRequired keys (or total=False)
publishes an outputSchema in which those properties are non-nullable, but
structuredContent materialises every key the tool did not return as an explicit null.

The response therefore violates the schema the same server just advertised, and a conforming
client rejects the call. The SDK's own client does exactly that, so these tools are effectively
unusable — even though TypedDict is one of the return shapes structured output documents.

The unstructured text block and structuredContent also disagree, so a client that skips
validation sees two different answers for the same call.

Steps to reproduce

import anyio, json
from typing import NotRequired, TypedDict
from mcp.server.mcpserver import MCPServer
from mcp import Client

class Person(TypedDict):
    name: str
    age: NotRequired[int]
    nickname: NotRequired[str]

mcp = MCPServer("td")

@mcp.tool()
def get_person() -> Person:
    return {"name": "Dave"}          # omits both optional keys

async def main():
    async with Client(mcp) as c:
        tools = await c.list_tools()
        print("outputSchema:", json.dumps(tools.tools[0].output_schema, sort_keys=True))
        r = await c.call_tool("get_person", {})
        print("structuredContent:", r.structured_content)

anyio.run(main)

Actual behaviour

outputSchema: {"properties": {"age": {"default": null, "title": "Age", "type": "integer"},
                              "name": {"title": "Name", "type": "string"},
                              "nickname": {"default": null, "title": "Nickname", "type": "string"}},
               "required": ["name"], "title": "Person", "type": "object"}

RuntimeError: Invalid structured content returned by tool get_person: None is not of type 'string'

Failed validating 'type' in schema['properties']['nickname']:
    {'default': None, 'title': 'Nickname', 'type': 'string'}
On instance['nickname']:
    None

structuredContent is {"name": "Dave", "age": None, "nickname": None} while the text block is
{"name": "Dave"}.

Expected behaviour

structuredContent is {"name": "Dave"} — a NotRequired key the tool omitted is absent, not
null — which validates against the published schema, and the call succeeds.

Root cause

_create_model_from_typeddict (src/mcp/server/mcpserver/utilities/func_metadata.py) gives each
non-required key a None default to make it optional on the generated model; the declared type
stays non-nullable, so the emitted schema is {"type": "integer", "default": null} and the key is
absent from required. convert_result then dumps with
model_dump(mode="json", by_alias=True) and no exclude_unset.

The file's own comment already prescribes the fix:

# For optional TypedDict fields, set default=None
# This makes them not required in the Pydantic model
# The model should use exclude_unset=True when dumping to get TypedDict semantics

Nothing applies it.

Why the tests do not catch it

tests/server/mcpserver/test_func_metadata.py::test_structured_output_typeddict covers exactly
this shape but asserts only the schema, and marks the tool body # pragma: no cover — the tool is
never invoked, so convert_result is never exercised for a partial TypedDict.

Not a duplicate of #3100 / PR #3118

That pair concerns the validation-vs-serialization JSON-schema mode. model_json_schema(mode="serialization")
produces byte-identical output for a (int, None) field, so it does not address this. Note #3118 does
edit adjacent lines, so ordering matters if both land.

Disclosure

Investigated with AI assistance (Claude Code). I have a fix and tests ready and will open a PR
linked to this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions