You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tools that return a string-keyed dict (dict[str, T]) lose any Annotated/Field metadata on the return type when the output schema is generated. A Field(description=...) on the return annotation shows up in the output schema for every other return type, but for dict[str, T] it's dropped.
I'd expect the description to be preserved, the same as other return types. Looks like the dict[str, T] branch in _try_create_model_and_schema builds the model from the Annotated-stripped type instead of the original annotation (there's an existing TODO on that line).
Have a simple, tested fix ready to go. Love the project and would love to contribute if possible!
Thanks for jumping on this, @anneheartrecord — this is the same approach I'd landed on (routing original_annotation through to _create_dict_model instead of the stripped type_expr), so +1 on the direction. One thing I confirmed while testing locally: un-annotated dict[str, str] and dict[str, Any] output schemas come out byte-for-byte identical, so it's safe as a non-breaking change.
Thanks @zlucas03, good to have that confirmed independently. The byte-for-byte match on un-annotated dict[str, str] and dict[str, Any] is exactly why I threaded original_annotation through instead of touching the stripped type_expr — existing schemas stay identical, only the annotated case changes. Fix is up in #2939.
Since #2939 was closed in the backlog sweep, I re-checked this on v2.3.0: still reproduces. The dict[str, T] branch of _create_output_model builds the schema from the stripped type_expr, and the TODO right above it already notes the lost Annotated metadata:
Use case is the one from the original report: the description a tool author puts on a dict return never reaches outputSchema, so clients get the shape but not what it means.
The fix is still the one-liner from #2939, Annotated[original_annotation, Field(title=...)] in that branch. I ran it against the 2.3.0 install: un-annotated dict[str, int] output stays byte-identical, the annotated case gains "description": "counts per key". Happy to rebase #2939 onto v2 if you'd rather reopen it than redo it.
Additional evidence for the metadata-loss path identified in this report: it also drops validation constraints, beyond the description loss already demonstrated.
For Annotated[dict[str, int], Field(min_length=1)], the generated schema omits minProperties and SDK output conversion accepts an empty dictionary. Pydantic's TypeAdapter for that exact annotation rejects {}. The corresponding annotated list retains minItems: 1 and SDK conversion rejects [], so the observed difference is specific to the direct dictionary path.
fromtypingimportAnnotatedimportpytestfrompydanticimportField, TypeAdapter, ValidationErrorfrommcp.server.mcpserver.utilities.func_metadataimportfunc_metadatadefproduce() ->Annotated[dict[str, int], Field(min_length=1)]:
return {}
withpytest.raises(ValidationError):
TypeAdapter(Annotated[dict[str, int], Field(min_length=1)]).validate_python({})
withpytest.raises(ValidationError): # Fails: DID NOT RAISEfunc_metadata(produce).convert_result(produce())
The deterministic failing test is on this test-only branch, with no production changes. Its first assertion checks the Pydantic control; the second fails because the SDK accepts the invalid output. Existing generic output tests pass in both environments.
uv run --frozen pytest tests/server/mcpserver/test_func_metadata.py::test_direct_dict_output_rejects_results_below_annotated_minimum_length -q
Python 3.14.7; Pydantic 2.12.5; MCP 2.3.0 (tag v2.3.0, 2118f14f8a19bc158d8a1cf90af58d85d187f849). Also reproduced using main source at 754254c08fe1c3eda18b69da3d336106fe8d72d6.
AI-assisted investigation and test reproduction, with a human in the loop.
Activity
zlucas03 commentedon Jun 22, 2026
Thanks for jumping on this, @anneheartrecord — this is the same approach I'd landed on (routing original_annotation through to _create_dict_model instead of the stripped type_expr), so +1 on the direction. One thing I confirmed while testing locally: un-annotated dict[str, str] and dict[str, Any] output schemas come out byte-for-byte identical, so it's safe as a non-breaking change.
anneheartrecord commentedon Jun 23, 2026
Thanks @zlucas03, good to have that confirmed independently. The byte-for-byte match on un-annotated
dict[str, str]anddict[str, Any]is exactly why I threadedoriginal_annotationthrough instead of touching the strippedtype_expr— existing schemas stay identical, only the annotated case changes. Fix is up in #2939.anneheartrecord commentedon Oct 8, 2026
Since #2939 was closed in the backlog sweep, I re-checked this on v2.3.0: still reproduces. The
dict[str, T]branch of_create_output_modelbuilds the schema from the strippedtype_expr, and theTODOright above it already notes the lostAnnotatedmetadata:python-sdk/src/mcp/server/mcpserver/utilities/func_metadata.py
Lines 509 to 511 in 91941ed
Use case is the one from the original report: the description a tool author puts on a dict return never reaches
outputSchema, so clients get the shape but not what it means.The fix is still the one-liner from #2939,
Annotated[original_annotation, Field(title=...)]in that branch. I ran it against the 2.3.0 install: un-annotateddict[str, int]output stays byte-identical, the annotated case gains"description": "counts per key". Happy to rebase #2939 onto v2 if you'd rather reopen it than redo it.inanna-malick commentedon Oct 14, 2026
Additional evidence for the metadata-loss path identified in this report: it also drops validation constraints, beyond the description loss already demonstrated.
For
Annotated[dict[str, int], Field(min_length=1)], the generated schema omitsminPropertiesand SDK output conversion accepts an empty dictionary. Pydantic'sTypeAdapterfor that exact annotation rejects{}. The corresponding annotated list retainsminItems: 1and SDK conversion rejects[], so the observed difference is specific to the direct dictionary path.The deterministic failing test is on this test-only branch, with no production changes. Its first assertion checks the Pydantic control; the second fails because the SDK accepts the invalid output. Existing generic output tests pass in both environments.
Python 3.14.7; Pydantic 2.12.5; MCP 2.3.0 (tag v2.3.0,
2118f14f8a19bc158d8a1cf90af58d85d187f849). Also reproduced using main source at754254c08fe1c3eda18b69da3d336106fe8d72d6.AI-assisted investigation and test reproduction, with a human in the loop.