From cad7cd627a7247457106254aed2e6f2bb9bcf070 Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Thu, 30 Jul 2026 13:27:04 -0700 Subject: [PATCH 1/5] fix(mcpserver): omit unreturned TypedDict keys from structuredContent 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) --- .../mcpserver/utilities/func_metadata.py | 22 ++++++- tests/server/mcpserver/test_func_metadata.py | 63 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..2910cd505d 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -139,7 +139,11 @@ def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: assert self.output_model is not None, "Output model must be set if output schema is defined" validated = self.output_model.model_validate(result) - structured_content = validated.model_dump(mode="json", by_alias=True) + # A TypedDict's non-required keys are absent, not null, when the tool omits + # them -- and the published outputSchema declares them non-nullable, so + # emitting nulls would violate the server's own schema. + exclude_unset = issubclass(self.output_model, _TypedDictOutputModel) + structured_content = validated.model_dump(mode="json", by_alias=True, exclude_unset=exclude_unset) return CallToolResult(content=unstructured_content, structured_content=structured_content) @@ -494,6 +498,17 @@ def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type return create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), **model_fields) +class _TypedDictOutputModel(BaseModel): + """Base for output models generated from a TypedDict return annotation. + + Non-required TypedDict keys are given a `None` default so they are optional on + the generated model, but `None` is not a valid value for their declared type and + the published `outputSchema` does not accept it. Dumping such a model has to + honour TypedDict semantics -- a key the tool did not return stays absent rather + than becoming an explicit null -- so `convert_result` keys off this base class. + """ + + def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: """Create a Pydantic model from a TypedDict. @@ -507,12 +522,13 @@ def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: if field_name not in required_keys: # 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 + # Dumped with exclude_unset=True (see `_TypedDictOutputModel`) so an + # omitted key stays omitted instead of serializing as null model_fields[field_name] = (field_type, None) else: model_fields[field_name] = field_type - return create_model(td_type.__name__, **model_fields) + return create_model(td_type.__name__, __base__=_TypedDictOutputModel, **model_fields) def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..0fc9c3658b 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -5,9 +5,10 @@ # pyright: reportUnknownLambdaType=false from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated, Any, Final, NamedTuple, TypedDict +from typing import Annotated, Any, Final, NamedTuple, NotRequired, TypedDict import annotated_types +import jsonschema import pytest from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult @@ -811,6 +812,66 @@ def func_returning_typeddict_required() -> PersonTypedDictRequired: # pragma: n } +def test_typeddict_output_omits_keys_the_tool_did_not_return(): + """A non-required TypedDict key the tool omits stays absent from structuredContent. + + SDK-defined: those keys carry a `None` default so they are optional on the generated + model, but the published outputSchema declares them non-nullable, so serializing them + as explicit nulls would make every such tool emit content violating the server's own + schema (and a conforming client reject the call). + """ + + class Person(TypedDict): + name: str + age: NotRequired[int] + nickname: NotRequired[str] + + def get_person() -> Person: + return {"name": "Dave"} + + meta = func_metadata(get_person) + # Call the real function rather than restating its return value: the existing + # tests in this file never invoke the tool body, which is why this went unnoticed. + result = meta.convert_result(get_person()) + + assert isinstance(result, CallToolResult) + assert result.structured_content == {"name": "Dave"} + + # The load-bearing assertion: the payload has to satisfy the schema this same + # metadata published, which is the contract a client validates against. + assert meta.output_schema is not None + jsonschema.validate(instance=result.structured_content, schema=meta.output_schema) + + # A key the tool DOES return still travels, including a falsy one. + both = meta.convert_result({"name": "Dave", "age": 0}) + assert isinstance(both, CallToolResult) + assert both.structured_content == {"name": "Dave", "age": 0} + jsonschema.validate(instance=both.structured_content, schema=meta.output_schema) + + +def test_total_false_typeddict_output_omits_every_unreturned_key(): + """`total=False` makes every key non-required, so an empty return stays empty. + + SDK-defined: pins the degenerate case, where the old behaviour turned `{}` into a + payload of nothing but nulls. + """ + + class Partial(TypedDict, total=False): + name: str + age: int + + def get_partial() -> Partial: + return {} + + meta = func_metadata(get_partial) + result = meta.convert_result(get_partial()) + + assert isinstance(result, CallToolResult) + assert result.structured_content == {} + assert meta.output_schema is not None + jsonschema.validate(instance=result.structured_content, schema=meta.output_schema) + + def test_structured_output_ordinary_class(): """Test structured output with ordinary annotated classes""" From 8d306c064262a838dfcfa624017c231d801bcc1e Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Thu, 30 Jul 2026 13:27:05 -0700 Subject: [PATCH 2/5] fix(tests): import NotRequired from typing_extensions for Python 3.10 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. --- tests/server/mcpserver/test_func_metadata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 0fc9c3658b..139aa54b85 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -5,7 +5,7 @@ # pyright: reportUnknownLambdaType=false from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated, Any, Final, NamedTuple, NotRequired, TypedDict +from typing import Annotated, Any, Final, NamedTuple, TypedDict import annotated_types import jsonschema @@ -13,6 +13,7 @@ from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult from pydantic import BaseModel, Field +from typing_extensions import NotRequired from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata From 826bde01869cacb237858a5787c81bf7a527402f Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Thu, 30 Jul 2026 13:27:05 -0700 Subject: [PATCH 3/5] fix(mcpserver): resolve TypedDict hints via typing_extensions for Python 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. --- src/mcp/server/mcpserver/utilities/func_metadata.py | 7 +++++-- tests/server/mcpserver/test_func_metadata.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 2910cd505d..b799e9ecbf 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Sequence from itertools import chain from types import GenericAlias -from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints +from typing import Annotated, Any, Union, cast, get_args, get_origin import anyio import anyio.to_thread @@ -13,7 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind -from typing_extensions import is_typeddict +from typing_extensions import get_type_hints, is_typeddict from typing_inspection.introspection import ( UNKNOWN, AnnotationSource, @@ -514,6 +514,9 @@ def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: The created model will have the same name and fields as the TypedDict. """ + # `typing_extensions.get_type_hints` strips the `Required`/`NotRequired` qualifier on + # every supported version; `typing.get_type_hints` only does so from 3.11, and below + # that the bare qualifier reaches `create_model`, which pydantic rejects outright. type_hints = get_type_hints(td_type) required_keys = getattr(td_type, "__required_keys__", set(type_hints.keys())) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 139aa54b85..9326a8db6b 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -5,7 +5,7 @@ # pyright: reportUnknownLambdaType=false from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated, Any, Final, NamedTuple, TypedDict +from typing import Annotated, Any, Final, NamedTuple import annotated_types import jsonschema @@ -13,7 +13,10 @@ from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult from pydantic import BaseModel, Field -from typing_extensions import NotRequired + +# Pydantic requires `typing_extensions.TypedDict` (not `typing.TypedDict`) below 3.12, +# otherwise a `NotRequired` qualifier is rejected as invalid in the context it is defined. +from typing_extensions import NotRequired, TypedDict from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata From ec734d15be54358a7a3b1a7069cfe10dc8a1c92a Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Thu, 30 Jul 2026 13:27:05 -0700 Subject: [PATCH 4/5] docs(structured-output): document NotRequired key omission 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. --- docs/servers/structured-output.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..8bb27cf35e 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -102,6 +102,11 @@ Not every shape deserves a class. A `TypedDict` produces the same schema: A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). +A key marked `NotRequired` (or any key at all under `total=False`) follows `TypedDict` semantics: if your tool leaves it out of the returned dict, it is **absent** from `structured_content` rather than present as `null`. That matches the published `outputSchema`, which declares those properties with their own type and simply omits them from `required`. + +!!! note + Below Python 3.12, import `TypedDict` from `typing_extensions` rather than `typing` whenever you use `NotRequired` — pydantic needs the `typing_extensions` variant to see the qualifier. + ## A dataclass Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes. From e8cc6f6cfc898eabcda77c5fb6a485588a93ec61 Mon Sep 17 00:00:00 2001 From: Sainikhil Juluri Date: Thu, 30 Jul 2026 18:33:01 -0700 Subject: [PATCH 5/5] test(mcpserver): pin nested TypedDict and explicit-None omission behaviour 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) --- tests/server/mcpserver/test_func_metadata.py | 87 +++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 9326a8db6b..537e7b9b53 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -12,7 +12,7 @@ import pytest from dirty_equals import IsPartialDict from mcp_types import CallToolResult, InputRequiredResult -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError # Pydantic requires `typing_extensions.TypedDict` (not `typing.TypedDict`) below 3.12, # otherwise a `NotRequired` qualifier is rejected as invalid in the context it is defined. @@ -876,6 +876,91 @@ def get_partial() -> Partial: jsonschema.validate(instance=result.structured_content, schema=meta.output_schema) +def test_nested_typeddict_output_omits_keys_the_tool_did_not_return(): + """The omission rule reaches a TypedDict nested inside another one. + + SDK-defined: only the top-level return annotation becomes the output model, so a + nested TypedDict is serialized by pydantic rather than by `convert_result`. Pins + that an inner non-required key is still absent instead of null. + """ + + class Address(TypedDict): + city: str + postcode: NotRequired[str] + + class Contact(TypedDict): + name: str + address: Address + phone: NotRequired[str] + + def get_contact() -> Contact: + return {"name": "Dave", "address": {"city": "Berlin"}} + + meta = func_metadata(get_contact) + result = meta.convert_result(get_contact()) + + assert isinstance(result, CallToolResult) + assert result.structured_content == {"name": "Dave", "address": {"city": "Berlin"}} + assert meta.output_schema is not None + jsonschema.validate(instance=result.structured_content, schema=meta.output_schema) + + +def test_typeddict_output_distinguishes_an_explicit_none_from_an_unset_key(): + """A key the tool set to `None` is kept; the same key left out stays absent. + + SDK-defined: `exclude_unset` keys off what the tool actually returned, not off the + value, so a nullable key carries a deliberate `null` through while an omitted one + does not appear at all. Guards against a fix that strips every falsy value. + """ + + class Profile(TypedDict): + name: str + age: NotRequired[int | None] + + def get_profile() -> Profile: + return {"name": "Dave", "age": None} + + meta = func_metadata(get_profile) + assert meta.output_schema is not None + + explicit_null = meta.convert_result(get_profile()) + assert isinstance(explicit_null, CallToolResult) + assert explicit_null.structured_content == {"name": "Dave", "age": None} + jsonschema.validate(instance=explicit_null.structured_content, schema=meta.output_schema) + + unset = meta.convert_result({"name": "Dave"}) + assert isinstance(unset, CallToolResult) + assert unset.structured_content == {"name": "Dave"} + jsonschema.validate(instance=unset.structured_content, schema=meta.output_schema) + + +def test_typeddict_output_rejects_a_none_its_schema_does_not_allow(): + """`None` on a non-nullable key is refused rather than serialized. + + SDK-defined: the `None` default that makes a non-required key optional on the + generated model is not a value the key accepts, so returning one fails validation + instead of emitting content the published outputSchema rejects. + """ + + class Person(TypedDict): + name: str + age: NotRequired[int] + + def get_person() -> Person: + return {"name": "Dave"} + + meta = func_metadata(get_person) + + # Leaving the key out is the supported way to say "no age"... + omitted = meta.convert_result(get_person()) + assert isinstance(omitted, CallToolResult) + assert omitted.structured_content == {"name": "Dave"} + + # ...whereas handing back an explicit `None` is not a value `int` accepts. + with pytest.raises(ValidationError): + meta.convert_result({"name": "Dave", "age": None}) + + def test_structured_output_ordinary_class(): """Test structured output with ordinary annotated classes"""