Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -149,7 +149,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 @@ -499,10 +498,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.
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 @@ -545,8 +542,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
30 changes: 30 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 @@ -295,6 +296,19 @@ def func_dict_list() -> dict[str, list[int]]: # pragma: no cover
"title": "func_dict_listDictOutput",
}

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"},
"title": "func_typing_dictDictOutput",
}
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 @@ -774,6 +788,22 @@ def func_optional() -> str | None: # pragma: no cover
"title": "func_optionalOutput",
}

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": {"title": "Result", "type": "integer"}},
"required": ["result"],
"title": "func_new_typeOutput",
}
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