Repository navigation
[Bug]: Inconsistent structured output wrapping and opt-out for typing.Dict and typing sequence generics in func_metadata #3771
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
In src/mcp/server/mcpserver/utilities/func_metadata.py, return type introspection handles built-in types differently from their unsubscripted typing generic counterparts (typing.Dict, typing.List, typing.Tuple).
Issue 1: typing.Dict is forcibly wrapped in {"result": ...} instead of top-level object
When a tool returns dict or dict[str, Any], _create_output_model sets wrap_output = False and derives a top-level JSON object schema:
elif type_annotation is dict:
model = dict[str, Any]However, when a tool is annotated with typing.Dict (unsubscripted), get_origin(typing.Dict) is dict while get_args(typing.Dict) is (). In _create_output_model():
if origin is dict:
args = get_args(type_expr)
if len(args) == 2 and args[0] is str:
model = type_expr
else:
# dict with non-str keys needs wrapping
model = _create_wrapped_model(func_name, original_annotation)
wrap_output = TrueBecause len(args) == 0 != 2, it falls through to else (intended only for non-str keys like dict[int, str]). As a result:
dict: treated as top-level JSON object (wrap_output = False)typing.Dict: unexpectedly wrapped into{"result": ...}(wrap_output = True)
Issue 2: typing.List and typing.Tuple bypass the opt-out gate
In func_metadata():
if structured_output is None and (return_type_expr in (list, tuple) or _returns_content(return_type_expr)):
return FuncMetadata(arg_model=arguments_model)Because typing.List is not list (typing.List in (list, tuple) evaluates to False), tools annotated with -> typing.List or -> typing.Tuple fail to opt out and are forced through _create_output_model(), generating an unwanted wrapper schema { "result": typing.List }.
Example Code
import typing
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
def tool_built_in_dict() -> dict:
return {"status": "ok"}
def tool_typing_dict() -> typing.Dict:
return {"status": "ok"}
def tool_built_in_list() -> list:
return ["a", "b"]
def tool_typing_list() -> typing.List:
return ["a", "b"]
meta_bi_dict = func_metadata(tool_built_in_dict)
meta_ty_dict = func_metadata(tool_typing_dict)
meta_bi_list = func_metadata(tool_built_in_list)
meta_ty_list = func_metadata(tool_typing_list)
# Inconsistent wrapping:
print("dict wrap_output:", meta_bi_dict.wrap_output) # False
print("typing.Dict wrap_output:", meta_ty_dict.wrap_output) # True (Expected: False)
# Inconsistent opt-out:
print("list schema:", meta_bi_list.output_schema) # None
print("typing.List schema:", meta_ty_list.output_schema) # {'type': 'object', 'properties': {'result': ...}} (Expected: None)Proposed Fix
- In
_create_output_model():
elif origin is dict:
args = get_args(type_expr)
if len(args) == 2 and args[0] is str:
model = type_expr
elif len(args) == 0:
# Bare typing.Dict is equivalent to bare dict
model = dict[str, Any]
else:
model = _create_wrapped_model(func_name, original_annotation)
wrap_output = True- In
func_metadata():
if structured_output is None and (
return_type_expr in (list, tuple, typing.List, typing.Tuple) or _returns_content(return_type_expr)
):
return FuncMetadata(arg_model=arguments_model)Version
Python 3.10+, MCP Python SDK 2.x
Activity
Ben-Home commentedon Oct 28, 2026
chauvuusvn commentedon Oct 28, 2026
Thanks @Ben-Home — that makes complete sense. Normalizing the annotation upfront before branching is much cleaner than scattering alias checks across
func_metadataand_create_output_model.Treating unconstrained
dict(args == ()) asdict[str, Any]and guaranteeing wire contract parity across all spellings (dict,dict[str, Any],typing.Dict,typing.Dict[str, Any], pluslist/tuplevariants) completely solves the schema drift issue.I'm happy to open a PR implementing this
normalise-then-branchapproach along with the parametrized schema/serialization equality tests and the opt-out invariant test you outlined. Let me know if you'd like me to proceed with the PR.chauvuusvn commentedon Oct 28, 2026
@Ben-Home That makes complete sense. Normalising
originandargsupfront before branching eliminates the alias discrepancies at the root and keeps the wire contract consistent.I've implemented and verified this exact approach locally on top of
main:origin = get_origin(expr) or exprandargs = get_args(expr)upfront in bothfunc_metadataand_create_output_model.argsondict(bothdictandtyping.Dict) as unconstrained string-keyed dictionaries withwrap_output = False.(dict, dict[str, Any], typing.Dict, typing.Dict[str, Any])and sequence types asserting identicaloutputSchemaand serialized structures.list/tuplevsC[T]).6242 passed).Branch commit for reference:
8c0193c.Happy to open a PR if you'd like to take this in, or let me know if you prefer to structure it differently!