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. diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..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, @@ -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,11 +498,25 @@ 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. 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())) @@ -507,12 +525,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..537e7b9b53 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -5,13 +5,18 @@ # 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 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. +from typing_extensions import NotRequired, TypedDict from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.utilities.func_metadata import func_metadata @@ -811,6 +816,151 @@ 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_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"""