diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index e5721488f5..4cc7c086d6 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -2,6 +2,7 @@ import inspect import json import sys +import typing from collections.abc import Awaitable, Callable, Sequence from itertools import chain from typing import Annotated, Any, NotRequired, Union, cast, get_args, get_origin @@ -446,7 +447,9 @@ def func_metadata( else: original_annotation = effective_annotation - if structured_output is None and (return_type_expr in (list, tuple) or _returns_content(return_type_expr)): + if structured_output is None and ( + return_type_expr in (list, tuple, typing.List, typing.Tuple) or _returns_content(return_type_expr) + ): # Content blocks and the Image/Audio helpers are what the model reads, not data for the # application: a derived schema would advertise the block's own model as output_schema (and, # unless the tool builds its own CallToolResult, echo every block into structured_content). @@ -520,6 +523,9 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st # TODO: should we use the original annotation? We are losing any potential `Annotated` # metadata for Pydantic here: model = type_expr + elif len(args) == 0: + # Bare typing.Dict is equivalent to bare dict -> top-level JSON object + model = dict[str, Any] else: # dict with non-str keys needs wrapping model = _create_wrapped_model(func_name, original_annotation) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index f929fb3954..2df4690372 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -309,6 +309,17 @@ def func_typing_dict() -> typing.Dict[str, str]: # noqa: UP006 assert isinstance(result, CallToolResult) assert result.structured_content == {"name": "John"} + # Test bare typing.Dict (unparameterized) - should match bare dict behavior without wrapping + def func_bare_typing_dict() -> typing.Dict: # noqa: UP006 + return {"msg": "hello", "code": 200} + + meta_bare = func_metadata(func_bare_typing_dict) + assert meta_bare.output_schema == IsPartialDict(type="object") + assert not meta_bare.wrap_output + result_bare = meta_bare.convert_result(func_bare_typing_dict()) + assert isinstance(result_bare, CallToolResult) + assert result_bare.structured_content == {"msg": "hello", "code": 200} + # 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"} @@ -318,6 +329,31 @@ def func_dict_int_key() -> dict[int, str]: # pragma: no cover assert "result" in meta.output_schema["properties"] +def test_bare_typing_sequence_opts_out_of_structured_output(): + """Test that bare typing.List and typing.Tuple opt out of structured output like bare list/tuple.""" + def func_bare_list() -> list: + return ["a", "b"] + + def func_bare_typing_list() -> typing.List: # noqa: UP006 + return ["a", "b"] + + def func_bare_tuple() -> tuple: + return ("a", "b") + + def func_bare_typing_tuple() -> typing.Tuple: # noqa: UP006 + return ("a", "b") + + meta_list = func_metadata(func_bare_list) + meta_typing_list = func_metadata(func_bare_typing_list) + meta_tuple = func_metadata(func_bare_tuple) + meta_typing_tuple = func_metadata(func_bare_typing_tuple) + + assert meta_list.output_schema is None + assert meta_typing_list.output_schema is None + assert meta_tuple.output_schema is None + assert meta_typing_tuple.output_schema is None + + @pytest.mark.anyio async def test_lambda_function(): """Test lambda function schema and validation"""