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
importanyio, jsonfromtypingimportNotRequired, TypedDictfrommcp.server.mcpserverimportMCPServerfrommcpimportClientclassPerson(TypedDict):
name: strage: NotRequired[int]
nickname: NotRequired[str]
mcp=MCPServer("td")
@mcp.tool()defget_person() ->Person:
return {"name": "Dave"} # omits both optional keysasyncdefmain():
asyncwithClient(mcp) asc:
tools=awaitc.list_tools()
print("outputSchema:", json.dumps(tools.tools[0].output_schema, sort_keys=True))
r=awaitc.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 Nonedefault 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.
Release line
2.x (
main), reproduced ata4f4ccd0.Bug description
A tool whose return annotation is a
TypedDictwithNotRequiredkeys (ortotal=False)publishes an
outputSchemain which those properties are non-nullable, butstructuredContentmaterialises every key the tool did not return as an explicitnull.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
structuredContentalso disagree, so a client that skipsvalidation sees two different answers for the same call.
Steps to reproduce
Actual behaviour
structuredContentis{"name": "Dave", "age": None, "nickname": None}while the text block is{"name": "Dave"}.Expected behaviour
structuredContentis{"name": "Dave"}— aNotRequiredkey the tool omitted is absent, notnull — 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 eachnon-required key a
Nonedefault to make it optional on the generated model; the declared typestays non-nullable, so the emitted schema is
{"type": "integer", "default": null}and the key isabsent from
required.convert_resultthen dumps withmodel_dump(mode="json", by_alias=True)and noexclude_unset.The file's own comment already prescribes the fix:
Nothing applies it.
Why the tests do not catch it
tests/server/mcpserver/test_func_metadata.py::test_structured_output_typeddictcovers exactlythis shape but asserts only the schema, and marks the tool body
# pragma: no cover— the tool isnever invoked, so
convert_resultis 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 doesedit 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.