Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 24 additions & 5 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()))

Expand All @@ -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]:
Expand Down
154 changes: 152 additions & 2 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""

Expand Down
Loading