Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}

## Dictionaries

`dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped:
`dict[str, ...]` (or `typing.Dict[str, ...]`) is the one generic that already *is* a JSON object, so it isn't wrapped:

```python title="server.py" hl_lines="9"
--8<-- "docs_src/structured_output/tutorial006.py"
Expand Down
9 changes: 2 additions & 7 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import sys
from collections.abc import Awaitable, Callable, Sequence
from itertools import chain
from types import GenericAlias
from typing import Annotated, Any, NotRequired, Union, cast, get_args, get_origin

import anyio
Expand Down Expand Up @@ -512,10 +511,8 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st
model = _create_wrapped_model(func_name, original_annotation)
wrap_output = True

# Handle GenericAlias types (list[str], dict[str, int], Union[str, int], etc.)
elif isinstance(type_expr, GenericAlias):
origin = get_origin(type_expr)

# Handle parameterized generics (list[str], typing.Dict[str, int], Union[str, int], etc.)
elif (origin := get_origin(type_expr)) is not None:
Comment thread
claude[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Tools returning Annotated[typing.Dict[str, X], Field(...)] lose their pydantic metadata after merging: constraints stop being enforced and descriptions leave output_schema. The dict branch now reached at func_metadata.py:502 builds the model from the stripped type_expr at :508, not from original_annotation. Fix: build the str-keyed dict model from the original annotation, e.g. Annotated[original_annotation, Field(title=...)], so metadata survives for both typing.Dict and builtin dict. The loss is pre-existing for dict[str, X] (see the TODO at :506); this change widens it to the typing.Dict spelling.

Why this was flagged

A tool annotated -> Annotated[typing.Dict[str, int], Field(min_length=1, description="scores")] reaches _create_output_model from func_metadata.py:443 with type_expr = typing.Dict[str, int] and original_annotation still carrying the Field. get_origin is dict, so :502-508 now returns Annotated[type_expr, Field(title=f"{func_name}DictOutput")], which discards the user's Field. On the base, typing.Dict[...] failed isinstance(type_expr, GenericAlias) and fell to the catch-all _create_wrapped_model(func_name, original_annotation), so the schema's result property carried minProperties/description and convert_result (:218) rejected an empty dict. After merging, the schema has neither and an empty dict validates. No safeguard applies: tests/server/mcpserver/test_func_metadata.py:1471 (test_preserves_pydantic_metadata) only covers Annotated[int, Field(gt=1)].

Verification: Triggers when a tool's return annotation is Annotated[typing.Dict[str, X], Field(...)]. In src/mcp/server/mcpserver/utilities/func_metadata.py, on the base typing.Dict[str, int] reached the final else: _create_wrapped_model(func_name, original_annotation). On the head, line 502 now matches and line 508 is built from the stripped type_expr, so the user's Field is gone.

Finding ID: f-f90d2307

if origin is dict:
args = get_args(type_expr)
if len(args) == 2 and args[0] is str:
Comment on lines +515 to 518

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the changed structured-output shape for typing.Dict

For existing tools annotated with typing.Dict[str, X], this changes both the advertised output schema and returned structured content from {"result": {...}} to the dictionary itself. However, no documentation accompanies this user-visible change. Update the existing docs/servers/structured-output.md page to explain that the legacy spelling now behaves like dict[str, X], including the changed wrapping behavior for existing consumers. The repository explicitly requires relevant documentation updates in the same change.

AGENTS.md reference: AGENTS.md:L144-L148

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -558,8 +555,6 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st

# Handle any other types not covered above
else:
# This includes typing constructs that aren't GenericAlias in Python 3.10
# (e.g., Union, Optional in some Python versions)
model = _create_wrapped_model(func_name, original_annotation)
wrap_output = True

Expand Down
28 changes: 28 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# pyright: reportMissingParameterType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownLambdaType=false
import typing
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Any, Final, NamedTuple, NotRequired, Required, TypedDict
Expand Down Expand Up @@ -293,6 +294,18 @@ def func_dict_list() -> dict[str, list[int]]: # pragma: no cover
"additionalProperties": {"type": "array", "items": {"type": "integer"}},
}

def func_typing_dict() -> typing.Dict[str, str]: # noqa: UP006
return {"name": "John"}

meta = func_metadata(func_typing_dict)
assert meta.output_schema == {
"type": "object",
"additionalProperties": {"type": "string"},
}
result = meta.convert_result(func_typing_dict())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"name": "John"}

# Test dict[int, str] - should be wrapped since key is not str
def func_dict_int_key() -> dict[int, str]: # pragma: no cover
return {1: "a", 2: "b"}
Expand Down Expand Up @@ -762,6 +775,21 @@ def func_optional() -> str | None: # pragma: no cover
"required": ["result"],
}

UserId = typing.NewType("UserId", int)

def func_new_type() -> UserId:
return UserId(1)

meta = func_metadata(func_new_type)
assert meta.output_schema == {
"type": "object",
"properties": {"result": {"type": "integer"}},
"required": ["result"],
}
result = meta.convert_result(func_new_type())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"result": 1}


def test_structured_output_dataclass():
"""Test structured output with dataclass return types"""
Expand Down
Loading