fix(mcpserver): omit unreturned TypedDict keys from structuredContent
Fixes #3224
Problem
A tool returning a TypedDict with NotRequired keys (or total=False) publishes an
outputSchema declaring those properties non-nullable, while convert_result materialises
every key the tool did not return as an explicit null. The response violates the schema the
same server advertised, so a conforming client rejects the call — including this SDK's own:
RuntimeError: Invalid structured content returned by tool get_person: None is not of type 'string'
Those tools are therefore unusable, though TypedDict is one of the documented structured-output
return shapes. structuredContent and the unstructured text block also disagreed, so a client
skipping validation saw two different answers.
Solution
_create_model_from_typeddict gives non-required keys a None default to make them optional on
the generated model. The file's own comment already said such a model "should use
exclude_unset=True when dumping to get TypedDict semantics" — nothing did. This marks those
models with a private base class (_TypedDictOutputModel) and honours it in convert_result, so
a key the tool omitted stays omitted.
Two deliberate choices:
- Scoped to TypedDict-derived models. Applying
exclude_unsetto every output shape would
change payloads forBaseModel, dataclass and wrapped ({"result": ...}) returns, where
emitting defaults is correct. The marker base class keeps the blast radius to exactly the
shape that is broken, and avoids threading another value out of
_try_create_model_and_schemaalongsidewrap_output. - The published
outputSchemais unchanged. Widening the annotation tofield_type | None
would make the null legal, butabsentandnullare not the same thing forNotRequired,
and it would alter the wire format of every existing TypedDict tool. Because only the dump
side changes, no existing expectation moves — including
test_structured_output_typeddict, which hard-codes the current schema.
The default: null still present in the schema is now cosmetic (default does not constrain
instances). Happy to strip it in a follow-up if you would rather the schema not advertise a
default it can never legally take.
A second, pre-existing bug this uncovered: NotRequired is unusable on Python 3.10
Tracked separately in #3227.
Adding a test that actually builds a NotRequired TypedDict surfaced a separate defect. On
Python 3.10 the tool does not merely return bad content — it cannot be registered at all:
pydantic.errors.PydanticForbiddenQualifier: The annotation 'NotRequired[int]' contains the
'typing.NotRequired' type qualifier, which is invalid in the context it is defined.
typing.get_type_hints only strips the Required/NotRequired qualifier from 3.11 onwards, so
below that the bare qualifier reaches create_model and pydantic rejects it. func_metadata()
raises, so the failure lands at decoration time, before the tool can ever be called.
This predates this branch. I confirmed it by running the same reproducer against unmodified
main (a4f4ccd0) in a separate worktree — identical crash. It is not introduced here; it is
simply the first time the suite exercises the path, since test_structured_output_typeddict uses
all-required keys and never instantiates the annotation.
The fix is one import: typing_extensions.get_type_hints strips the qualifier on every
supported version, so there is no version branch. That matters here — a sys.version_info branch
would be one-sided on whichever interpreter is measuring, and coverage runs at
fail_under = 100 independently on all five.
Say the word if you would rather have this as its own PR and I will split it out; I kept them
together because the new tests cannot pass on 3.10 without it, so landing them separately would
knowingly leave main red on a supported version.
How to test
uv sync --frozen --all-extras --dev
uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py -q
uv run --frozen --python 3.10 --all-extras --dev pytest -q -n auto # the version that was broken
./scripts/test # coverage + strict-no-cover
Verified on this branch:
- Python 3.10 specifically → 5582 passed, 10 skipped, 1 xfailed. I ran the whole suite on a
real 3.10 interpreter rather than trusting the default one; an earlier revision of this branch
passed locally and still broke all four 3.10 jobs, which is what led to the section above. ./scripts/test→ 5582 passed,TOTAL 49631 Stmts 0 Miss 3922 Branch 0 BrPart 100.00%,
strict-no-covercleanuv run --frozen ruff check ./ruff format --check .→ cleanuv run --frozen pyright --pythonversion 3.10→ the only 2 errors are pre-existing and unrelated
(tests/transports/stdio/test_lifecycle.pyusesos.waitid, which does not exist on macOS)markdownlintwith the repo's own config → clean, and clean onmaintoo
Red-before/green-after was verified rather than assumed — with git stash push -- src/ (tests
kept, fix removed) both new tests fail on the injected nulls:
Left contains 2 more items:
{'age': None, 'name': None}
Full diff:
- {}
+ {...}
End-to-end through a real Client(MCPServer(...)), the reproducer in the issue goes from
RuntimeError: Invalid structured content ... to structuredContent: {'name': 'Dave'}, matching
the text block.
Tests added
Two cases in tests/server/mcpserver/test_func_metadata.py:
test_typeddict_output_omits_keys_the_tool_did_not_return— an omittedNotRequiredkey is
absent, a returned one still travels (including a falsy0)test_total_false_typeddict_output_omits_every_unreturned_key— the degeneratetotal=False
case, where the old behaviour turned{}into a payload of nothing but nulls
Both assert the payload against meta.output_schema with jsonschema.validate, so they pin the
actual contract — structuredContent must satisfy the schema this server published — rather than
just a dict shape.
They also call the tool function instead of restating its return value, and add no
# pragma: no cover. That is the point: the existing tests for this shape never invoke the tool
body, which is why the bug survived — and building the annotation for real is what exposed the
3.10 crash as well.
The test module now takes TypedDict from typing_extensions, which pydantic requires below 3.12
for a NotRequired qualifier to be honoured.
Docs
Per AGENTS.md ("update the relevant page(s) under docs/ in the same PR"),
docs/servers/structured-output.md said a TypedDict's structured_content was "identical to the
BaseModel version". That is no longer true for omitted keys, so the TypedDict section now states
the omission rule and notes the typing_extensions import requirement below 3.12.
Disclosure
Written with AI assistance (Claude Code). Filed by me as the human contributor -- I own this
change and will answer any questions on the implementation or the trade-offs above.