Skip to content

fix(mcpserver): omit unreturned TypedDict keys from structuredContent - #3225

Open
sainikhiljuluri wants to merge 5 commits into
modelcontextprotocol:mainfrom
sainikhiljuluri:fix/typeddict-notrequired-structured-output
Open

fix(mcpserver): omit unreturned TypedDict keys from structuredContent#3225
sainikhiljuluri wants to merge 5 commits into
modelcontextprotocol:mainfrom
sainikhiljuluri:fix/typeddict-notrequired-structured-output

Conversation

@sainikhiljuluri

@sainikhiljuluri sainikhiljuluri commented Jul 30, 2026

Copy link
Copy Markdown

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_unset to every output shape would
    change payloads for BaseModel, 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_schema alongside wrap_output.
  • The published outputSchema is unchanged. Widening the annotation to field_type | None
    would make the null legal, but absent and null are not the same thing for NotRequired,
    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 specifically5582 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/test5582 passed, TOTAL 49631 Stmts 0 Miss 3922 Branch 0 BrPart 100.00%,
    strict-no-cover clean
  • uv run --frozen ruff check . / ruff format --check . → clean
  • uv run --frozen pyright --pythonversion 3.10 → the only 2 errors are pre-existing and unrelated
    (tests/transports/stdio/test_lifecycle.py uses os.waitid, which does not exist on macOS)
  • markdownlint with the repo's own config → clean, and clean on main too

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 omitted NotRequired key is
    absent, a returned one still travels (including a falsy 0)
  • test_total_false_typeddict_output_omits_every_unreturned_key — the degenerate total=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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
sainikhiljuluri and others added 4 commits July 30, 2026 13:27
A tool annotated to return a TypedDict with `NotRequired` keys (or
`total=False`) published an outputSchema declaring those properties
non-nullable, while `convert_result` materialised every key the tool did
not return as an explicit `null`. The response therefore violated the
schema the same server had just advertised, and a conforming client -- the
SDK's own included -- rejected the call:

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

That made every such tool unusable, even though TypedDict is one of the
return shapes structured output documents. The unstructured text block and
structuredContent also disagreed, so a client skipping validation saw two
different answers.

`_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
noted that such a model "should use exclude_unset=True when dumping to get
TypedDict semantics", but nothing did. Mark those models with a private
base class and honour it in `convert_result`, so an omitted key stays
omitted.

Scoped deliberately to TypedDict-derived models: `exclude_unset` applied
to every output shape would change payloads for BaseModel, dataclass and
wrapped returns, where emitting defaults is correct.

The published outputSchema is unchanged, so no existing expectation moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
typing.NotRequired is 3.11+, but this project supports >=3.10, so the new
tests broke collection on every 3.10 job and pyright (which also resolves
against 3.10) reported the TypedDict keys as required.

Matches the convention already used elsewhere in the suite.
…hon 3.10

`typing.get_type_hints` only strips the `Required`/`NotRequired` qualifier from
3.11 onwards. Below that the bare qualifier reached `create_model`, so on 3.10 --
a supported version -- ANY tool returning a TypedDict with `NotRequired` keys
failed at registration with PydanticForbiddenQualifier, before it could ever be
called. `typing_extensions.get_type_hints` strips it on every supported version.

Verified against unmodified upstream in a separate worktree: this crash predates
this branch and is not introduced by the structuredContent change.

Also switches the test module to `typing_extensions.TypedDict`, which pydantic
requires below 3.12 for `NotRequired` to be honoured.
AGENTS.md requires user-visible behaviour changes to update docs in the same PR.
The TypedDict section claimed structured_content was "identical to the BaseModel
version", which is no longer true for keys the tool omits.
…viour

Two cases the existing coverage left open, both of which a conforming client
would notice:

- A TypedDict nested inside the returned one. Only the top-level annotation
  becomes the output model, so the nested value is serialised by pydantic
  rather than by convert_result; without the fix an inner NotRequired key the
  tool omitted still travelled as an explicit null.

- Explicit `None` versus an unset key on a nullable field. exclude_unset keys
  off what the tool actually returned, not off the value, so a deliberate
  `null` is preserved while an omitted key stays absent. This guards against a
  narrower fix that strips every falsy or null value.

Both fail against unmodified upstream in a worktree and pass here. A third
test pins the adjacent boundary -- `None` on a non-nullable key is refused by
validation rather than serialised -- which already held before this branch and
is included as a characterisation test, not a regression test.

Every assertion validates the emitted payload against the outputSchema the
same metadata published, matching the existing tests in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant