From f5c406f0a323dd17197bda8e533ac83cb0f8c7d5 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Sun, 21 Jun 2026 10:34:51 +0800 Subject: [PATCH 1/2] fix(mcpserver): preserve Annotated/Field metadata for dict[str, T] return types When a tool returns `Annotated[dict[str, T], Field(description="...")]`, the `_try_create_model_and_schema` dict branch was passing the unwrapped `type_expr` (i.e. `dict[str, T]`) to `_create_dict_model` instead of `original_annotation`, so any `Field` description or other Pydantic metadata was dropped from the output schema. Fix by passing `original_annotation` so the `RootModel` picks it up. Fixes #2935 --- .../mcpserver/utilities/func_metadata.py | 6 ++--- tests/server/mcpserver/test_func_metadata.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 6c553fbab9..03e10ce823 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -349,9 +349,9 @@ def _try_create_model_and_schema( if origin is dict: args = get_args(type_expr) if len(args) == 2 and args[0] is str: - # TODO: should we use the original annotation? We are losing any potential `Annotated` - # metadata for Pydantic here: - model = _create_dict_model(func_name, type_expr) + # Use the original annotation (which may be `Annotated[dict[str, T], Field(...)]`) + # so that any Annotated metadata (e.g. Field description) is preserved in the schema. + model = _create_dict_model(func_name, original_annotation) 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 2763b3f503..d2575809b6 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -245,6 +245,31 @@ def func_dict_int_key() -> dict[int, str]: # pragma: no cover assert "result" in meta.output_schema["properties"] +def test_structured_output_dict_str_preserves_annotated_metadata(): + """dict[str, T] return types should preserve Annotated/Field metadata in output schema. + + Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/2935 + """ + + def get_config() -> Annotated[dict[str, int], Field(description="Configuration values")]: # pragma: no cover + return {"timeout": 30} + + meta = func_metadata(get_config) + assert meta.output_schema is not None + assert meta.output_schema.get("description") == "Configuration values", ( + f"Expected 'description' in schema, got: {meta.output_schema}" + ) + + # Additional metadata (title, max_length, etc.) should also be preserved + def get_headers() -> Annotated[dict[str, str], Field(description="HTTP headers", title="Headers")]: # pragma: no cover + return {"Content-Type": "application/json"} + + meta2 = func_metadata(get_headers) + assert meta2.output_schema is not None + assert meta2.output_schema.get("description") == "HTTP headers" + assert meta2.output_schema.get("title") == "Headers" + + @pytest.mark.anyio async def test_lambda_function(): """Test lambda function schema and validation""" From b6fcff1dc7c54a3ad4aae96ee7fa23e12156c818 Mon Sep 17 00:00:00 2001 From: Charles Cheng Date: Sun, 21 Jun 2026 12:28:37 +0800 Subject: [PATCH 2/2] style: wrap long function signature to pass ruff-format Pre-commit ruff-format check failed because the return type annotation for get_headers() in the test exceeded line-length limits. Wrap it to three lines to satisfy the formatter. --- tests/server/mcpserver/test_func_metadata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index d2575809b6..f82f708d29 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -261,7 +261,9 @@ def get_config() -> Annotated[dict[str, int], Field(description="Configuration v ) # Additional metadata (title, max_length, etc.) should also be preserved - def get_headers() -> Annotated[dict[str, str], Field(description="HTTP headers", title="Headers")]: # pragma: no cover + def get_headers() -> Annotated[ + dict[str, str], Field(description="HTTP headers", title="Headers") + ]: # pragma: no cover return {"Content-Type": "application/json"} meta2 = func_metadata(get_headers)