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.
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.