Skip to content

typing.Dict[str, X] return annotation is wrapped in {"result": ...}, unlike dict[str, X] #3745

Open

Description

@benjamin-di

Initial Checks

Release line

2.x (current stable)

Description

-> typing.Dict[str, Any] and -> dict[str, Any] are the same type, but MCPServer gives them different output schemas and different structuredContent:

  • -> dict[str, Any]: outputSchema is the object itself ({"type": "object", "additionalProperties": true, ...}) and the result is {"ok": true}.
  • -> typing.Dict[str, Any]: the result is wrapped, so outputSchema is {"properties": {"result": {...}}, "required": ["result"], ...} and the result is {"result": {"ok": true}}.

So modernising annotations changes what a tool returns on the wire. Ruff's UP006 (and pyupgrade) rewrite Dict to dict automatically, and that silently changed one of our tools' output shape. A client that had cached the tool's earlier output schema then rejected its results.

Expected: both spellings behave like dict[str, Any] does today (and typing.List[...], typing.Tuple[...] like their builtin forms).

Cause: in _create_output_model (src/mcp/server/mcpserver/utilities/func_metadata.py), the generic branch is elif isinstance(type_expr, GenericAlias):, with GenericAlias from types. typing.Dict[str, Any] is a typing._GenericAlias, not a types.GenericAlias, so it skips the origin is dict case and falls through to the final else, which wraps it.

Possible fix: test the origin instead of the alias class, e.g. elif get_origin(type_expr) is not None:. The other generics that reach the final else today (Union, Optional, Literal, ...) all have an origin other than dict, so they would still be wrapped as now. I'm happy to send a PR with tests if a maintainer assigns this to me.

Related, but different: #3725 is about bare dict/list/tuple without parameters.

Example Code

import asyncio
import typing

from mcp.server.mcpserver import MCPServer

server = MCPServer(name="repro")


@server.tool()
def typing_dict() -> typing.Dict[str, typing.Any]:
    return {"ok": True}


@server.tool()
def builtin_dict() -> dict[str, typing.Any]:
    return {"ok": True}


async def main() -> None:
    tools = {tool.name: tool for tool in await server.list_tools()}
    for name in ("typing_dict", "builtin_dict"):
        result = await server.call_tool(name, {})
        print(name, tools[name].output_schema, result.structured_content)


asyncio.run(main())

Output:

typing_dict {'properties': {'result': {'additionalProperties': True, 'title': 'Result', 'type': 'object'}}, 'required': ['result'], 'title': 'typing_dictOutput', 'type': 'object'} {'result': {'ok': True}}
builtin_dict {'additionalProperties': True, 'title': 'builtin_dictDictOutput', 'type': 'object'} {'ok': True}

Python & MCP Python SDK

Python 3.12
mcp 2.3.0 (also on main: func_metadata.py still uses isinstance(type_expr, GenericAlias))

Prepared with the help of Claude Code.

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 23, 2026
  2. added a commit that references this issue on Oct 23, 2026
    a466c65
  3. Jihaochang1 commented on Oct 23, 2026

    @Jihaochang1

    I've analyzed this issue and confirmed the bug. The root cause is exactly as described - typing.Dict[str, Any] is a typing._GenericAlias, not a types.GenericAlias.

    I've prepared a minimal fix that changes:

    elif isinstance(type_expr, GenericAlias):

    to:

    elif get_origin(type_expr) is not None:

    This ensures consistent behavior between typing.Dict and dict annotations, preventing silent schema changes when Ruff UP006 rewrites old-style annotations.

    I'd like to contribute this fix. Please assign me to this issue so I can submit the PR.

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