Skip to content

Commit e8cc6f6

Browse files
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) <noreply@anthropic.com>
1 parent ec734d1 commit e8cc6f6

1 file changed

Lines changed: 86 additions & 1 deletion

File tree

tests/server/mcpserver/test_func_metadata.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import pytest
1313
from dirty_equals import IsPartialDict
1414
from mcp_types import CallToolResult, InputRequiredResult
15-
from pydantic import BaseModel, Field
15+
from pydantic import BaseModel, Field, ValidationError
1616

1717
# Pydantic requires `typing_extensions.TypedDict` (not `typing.TypedDict`) below 3.12,
1818
# otherwise a `NotRequired` qualifier is rejected as invalid in the context it is defined.
@@ -876,6 +876,91 @@ def get_partial() -> Partial:
876876
jsonschema.validate(instance=result.structured_content, schema=meta.output_schema)
877877

878878

879+
def test_nested_typeddict_output_omits_keys_the_tool_did_not_return():
880+
"""The omission rule reaches a TypedDict nested inside another one.
881+
882+
SDK-defined: only the top-level return annotation becomes the output model, so a
883+
nested TypedDict is serialized by pydantic rather than by `convert_result`. Pins
884+
that an inner non-required key is still absent instead of null.
885+
"""
886+
887+
class Address(TypedDict):
888+
city: str
889+
postcode: NotRequired[str]
890+
891+
class Contact(TypedDict):
892+
name: str
893+
address: Address
894+
phone: NotRequired[str]
895+
896+
def get_contact() -> Contact:
897+
return {"name": "Dave", "address": {"city": "Berlin"}}
898+
899+
meta = func_metadata(get_contact)
900+
result = meta.convert_result(get_contact())
901+
902+
assert isinstance(result, CallToolResult)
903+
assert result.structured_content == {"name": "Dave", "address": {"city": "Berlin"}}
904+
assert meta.output_schema is not None
905+
jsonschema.validate(instance=result.structured_content, schema=meta.output_schema)
906+
907+
908+
def test_typeddict_output_distinguishes_an_explicit_none_from_an_unset_key():
909+
"""A key the tool set to `None` is kept; the same key left out stays absent.
910+
911+
SDK-defined: `exclude_unset` keys off what the tool actually returned, not off the
912+
value, so a nullable key carries a deliberate `null` through while an omitted one
913+
does not appear at all. Guards against a fix that strips every falsy value.
914+
"""
915+
916+
class Profile(TypedDict):
917+
name: str
918+
age: NotRequired[int | None]
919+
920+
def get_profile() -> Profile:
921+
return {"name": "Dave", "age": None}
922+
923+
meta = func_metadata(get_profile)
924+
assert meta.output_schema is not None
925+
926+
explicit_null = meta.convert_result(get_profile())
927+
assert isinstance(explicit_null, CallToolResult)
928+
assert explicit_null.structured_content == {"name": "Dave", "age": None}
929+
jsonschema.validate(instance=explicit_null.structured_content, schema=meta.output_schema)
930+
931+
unset = meta.convert_result({"name": "Dave"})
932+
assert isinstance(unset, CallToolResult)
933+
assert unset.structured_content == {"name": "Dave"}
934+
jsonschema.validate(instance=unset.structured_content, schema=meta.output_schema)
935+
936+
937+
def test_typeddict_output_rejects_a_none_its_schema_does_not_allow():
938+
"""`None` on a non-nullable key is refused rather than serialized.
939+
940+
SDK-defined: the `None` default that makes a non-required key optional on the
941+
generated model is not a value the key accepts, so returning one fails validation
942+
instead of emitting content the published outputSchema rejects.
943+
"""
944+
945+
class Person(TypedDict):
946+
name: str
947+
age: NotRequired[int]
948+
949+
def get_person() -> Person:
950+
return {"name": "Dave"}
951+
952+
meta = func_metadata(get_person)
953+
954+
# Leaving the key out is the supported way to say "no age"...
955+
omitted = meta.convert_result(get_person())
956+
assert isinstance(omitted, CallToolResult)
957+
assert omitted.structured_content == {"name": "Dave"}
958+
959+
# ...whereas handing back an explicit `None` is not a value `int` accepts.
960+
with pytest.raises(ValidationError):
961+
meta.convert_result({"name": "Dave", "age": None})
962+
963+
879964
def test_structured_output_ordinary_class():
880965
"""Test structured output with ordinary annotated classes"""
881966

0 commit comments

Comments
 (0)