Skip to content

[Bug]: Inconsistent structured output wrapping and opt-out for typing.Dict and typing sequence generics in func_metadata #3771

Open

Description

@chauvuusvn

Initial Checks

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 = True

Because 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

  1. 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
  1. 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

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Oct 27, 2026
  2. Ben-Home commented on Oct 28, 2026

    @Ben-Home

    Normalising the annotation before you branch is the change that fixes both cases at once, because both bugs come from asking a question about the alias instead of about the type. typing.Dict and dict describe the same type for every purpose that matters here, so any check that can answer differently for the two is answering a question nobody asked.

    Concretely, at the top of _create_output_model, collapse the annotation to its concrete form and branch only on that:

    • origin = get_origin(expr) or expr, then args = get_args(expr)
    • empty args on dict means string-keyed and unconstrained, not "needs wrapping": typing.Dict is exactly dict[str, Any] and should take the same wrap_output = False path, not the non-str-key path
    • the same normalisation applied in func_metadata makes (typing.List, typing.Tuple) unnecessary, because after collapse the membership test is (list, tuple) again

    The reason this belongs in one place is the wire contract. Wrapping is not cosmetic: a tool that returns a top-level object under one spelling and {"result": ...} under another gives two different outputSchema values and two different result shapes for identical semantics, and a client generated against one breaks on the other. A caller who writes dict locally and typing.Dict in a shared stub package has no way to see why the shape moved.

    Two things worth adding in the same change:

    1. A parametrised test over the spellings that must be indistinguishable: dict, dict[str, Any], typing.Dict, typing.Dict[str, Any], and the same four for list and tuple. Assert equality of both the generated outputSchema and the actual serialized result, not just that the call succeeds. That test is what makes the normalisation permanent rather than a patch to two branches.
    2. The opt-out gate and the wrapper both decide the same question independently, so one test that asserts "wrap_output False implies the output schema has no result property" catches the third spelling that will arrive later, which a per-branch fix cannot.

    Worth noting while you are in there: the unsubscripted typing aliases are deprecated since 3.9 (removal was planned, then deferred), so normalising them is also the one place a future deprecation warning can be absorbed instead of leaking into user code.

    We hit the same class from the server side in our MCP connector server: two argument models that were semantically identical produced different schemas depending on how they were annotated, and the fix was a single normalise-then-branch helper rather than a rule documented in a contributing guide.

  3. chauvuusvn commented on Oct 28, 2026

    @chauvuusvn
    Author

    Thanks @Ben-Home — that makes complete sense. Normalizing the annotation upfront before branching is much cleaner than scattering alias checks across func_metadata and _create_output_model.

    Treating unconstrained dict (args == ()) as dict[str, Any] and guaranteeing wire contract parity across all spellings (dict, dict[str, Any], typing.Dict, typing.Dict[str, Any], plus list/tuple variants) completely solves the schema drift issue.

    I'm happy to open a PR implementing this normalise-then-branch approach 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.

  4. chauvuusvn commented on Oct 28, 2026

    @chauvuusvn
    Author

    @Ben-Home That makes complete sense. Normalising origin and args upfront 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:

    • Collapsed origin = get_origin(expr) or expr and args = get_args(expr) upfront in both func_metadata and _create_output_model.
    • Treated empty args on dict (both dict and typing.Dict) as unconstrained string-keyed dictionaries with wrap_output = False.
    • Added the parametrized spelling parity tests for (dict, dict[str, Any], typing.Dict, typing.Dict[str, Any]) and sequence types asserting identical outputSchema and serialized structures.
    • Added invariant tests for the bare sequence opt-out gate (list / tuple vs C[T]).
    • Full test suite passes clean (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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions