Skip to content

@app.tool() raises NameError for parameter/return types defined later in the module on Python 3.14+ (PEP 649/749 deferred annotations) #3668

Description

@ao3575911

Initial Checks

Release line

2.x (current stable)

Description

Since Python 3.14 (PEP 649 / PEP 749), annotations are evaluated lazily, so this is valid without quotes or from __future__ import annotations:

@app.tool()
def search(q: Query) -> list[Hit]: ...

class Query(BaseModel): ...

MCPServer.tool() builds the Tool eagerly inside the decorator. The first thing it does is inspect.signature(fn) in find_resolved_parameters, which defaults to annotation_format=Format.VALUE. That forces fn.__annotate__ to run while Query doesn't exist yet, so registration crashes with a bare NameError:

File ".../mcp/server/mcpserver/tools/base.py", line 91, in from_function
    resolved_params = find_resolved_parameters(fn)
File ".../mcp/server/mcpserver/resolve.py", line 229, in find_resolved_parameters
    for name in inspect.signature(fn).parameters:
...
NameError: name 'Query' is not defined

Expected: either the tool registers (resolving annotations once the module has finished defining the types), or a clear InvalidSignature error that names the tool and the unresolved name.

Code path (v2.3.0; these files are byte-identical on main @ 91941ed):

Step Location Behaviour today
1 tools/base.py:89 find_context_parameter(fn) → utilities/context_injection.py:27 typing.get_type_hints(fn) NameError is swallowed and it returns None. So a ctx: Context parameter would be silently missed.
2 resolve.py:209-212 _type_hints() → typing.get_type_hints(..., include_extras=True) Swallowed, returns {}. So Annotated[_, Resolve(...)] markers would be silently dropped.
3 resolve.py:229 inspect.signature(fn).parameters Raises a bare NameError (the crash above). Only parameter names are needed here.
4 utilities/func_metadata.py:322 inspect.signature(func, eval_str=True) Wraps the NameError in InvalidSignature. This is what you hit if step 3 is patched, and with quoted annotations or from __future__ import annotations. The inline comment there already suggests model_rebuild later.

(The same eager pattern is at resolve.py:376 and in the resource decorator at server.py:873.)

What I verified locally:

  • I patched step 3 to use inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF). Registration then fails at step 4 with InvalidSignature: Unable to evaluate type annotations for callable 'search' (cause: NameError: name 'Query' is not defined). So step 3 alone is not enough.
  • With the decorator replaced by "collect fn, call app.add_tool(fn) after the classes are defined", the same function registers. list_tools() returns {'$ref': '#/$defs/Query2'} for the parameter, and call_tool validates and returns correctly. So deferring the build is enough.
  • Quoting does not help: q: "Query" gives InvalidSignature on 3.14. So does from __future__ import annotations on 3.13.5. Only defining the types before the decorated function works today.
  • The error type is inconsistent: unquoted annotations on 3.14+ raise a bare NameError (step 3), while quoted or __future__ annotations raise InvalidSignature (step 4).

Related but not duplicates:

Suggested fix (open to maintainers' preference):

  1. Defer schema building for tools, and the same for resources/prompts. @app.tool() would record the function and options. The Tool (func_metadata, context/resolve detection) would be built on first list_tools/call_tool, or at server start, and cached. Any NameError then surfaces at that point as InvalidSignature naming the tool and the missing name. Registration-time validation that doesn't need types (name, lambda check, duplicates) can stay eager.
  2. Independently, at resolve.py:229/:376 and server.py:873, only parameter names are used. On 3.14+ use inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF); on older versions, inspect.signature(fn) plus fn.__code__-based names, or a try/except. Then a bare NameError can't escape from those sites.
  3. Stop silently swallowing unresolvable hints in find_context_parameter and _type_hints. Once building is deferred, they'll resolve. If they still can't, raising InvalidSignature is safer than silently not injecting Context or dropping Resolve(...).
  4. Add a test module run on 3.14 (already in the CI matrix) that defines tool param/return models below the decorated function.

Workaround: define all models used in tool signatures above the @app.tool() function, or call app.add_tool(fn) after the models are defined. Quoting the annotation does not work.

Example Code

# repro: tool parameter/return types defined *after* the tool (valid on Python 3.14+, PEP 649/749)
from pydantic import BaseModel
from mcp.server.mcpserver import MCPServer

app = MCPServer("repro")

@app.tool()
def search(q: Query) -> list[Hit]:
    """Search."""
    return [Hit(id=1)]

class Query(BaseModel):
    text: str

class Hit(BaseModel):
    id: int

if __name__ == "__main__":
    import asyncio
    print([t.name for t in asyncio.run(app.list_tools())])

Full traceback (fresh run, Python 3.14.8):

Traceback (most recent call last):
  File "repro_fwdref.py", line 7, in <module>
    @app.tool()
     ~~~~~~~~^^
  File ".../site-packages/mcp/server/mcpserver/server.py", line 725, in decorator
    self.add_tool(
  File ".../site-packages/mcp/server/mcpserver/server.py", line 647, in add_tool
    self._tool_manager.add_tool(
  File ".../site-packages/mcp/server/mcpserver/tools/tool_manager.py", line 51, in add_tool
    tool = Tool.from_function(
  File ".../site-packages/mcp/server/mcpserver/tools/base.py", line 91, in from_function
    resolved_params = find_resolved_parameters(fn)
  File ".../site-packages/mcp/server/mcpserver/resolve.py", line 229, in find_resolved_parameters
    for name in inspect.signature(fn).parameters:
                ~~~~~~~~~~~~~~~~~^^^^
  File ".../lib/python3.14/inspect.py", line 3334, in signature
    return Signature.from_callable(obj, follow_wrapped=follow_wrapped,
  File ".../lib/python3.14/inspect.py", line 3049, in from_callable
    return _signature_from_callable(obj, sigcls=cls,
  File ".../lib/python3.14/inspect.py", line 2519, in _signature_from_callable
    return _signature_from_function(sigcls, obj,
  File ".../lib/python3.14/inspect.py", line 2342, in _signature_from_function
    annotations = get_annotations(func, globals=globals, locals=locals, eval_str=eval_str,
  File ".../lib/python3.14/annotationlib.py", line 987, in get_annotations
    ann = _get_dunder_annotations(obj)
  File ".../lib/python3.14/annotationlib.py", line 1180, in _get_dunder_annotations
    ann = getattr(obj, "__annotations__", None)
  File "repro_fwdref.py", line 8, in __annotate__
    def search(q: Query) -> list[Hit]:
                  ^^^^^
NameError: name 'Query' is not defined

The traceback is identical on 3.14.8 free-threaded and 3.15.0rc3 (only the stdlib line numbers differ).

Python & MCP Python SDK

mcp 2.3.0 (also reproduced against main @ 91941ed: the relevant files are unchanged)
pydantic 2.14.0
Python 3.14.8 (main, Oct  3 2026) [Clang 22.1.3]               -> NameError
Python 3.14.8 free-threading build                             -> NameError
Python 3.15.0rc3 (main, Oct  3 2026) [Clang 22.1.3]            -> NameError
Python 3.13.5 + `from __future__ import annotations`           -> InvalidSignature
OS: Linux-6.12.94+-x86_64-with-glibc2.41 (uv-managed CPython builds)

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 9, 2026
  2. epistemedeus commented on Oct 10, 2026

    @epistemedeus

    I reproduced the supplied example on main 91941ed4 with Python 3.14.8 and prepared a tools-only candidate: patch, reproduction and application notes.

    It defers only a missing annotation name during @tool registration, then builds the recorded function on its first list/get/call. Direct add_tool remains eager. Public add_tool subclass hooks still run at registration; a rejecting hook stores nothing and is not revived later. The first registration retains ownership across eager/pending duplicates, and parameter-name errors are rejected before a missing annotation can hide them.

    Independent replay on a fresh checkout of the pinned official source passed 436 focused/adjacent tests. The two additional compatibility probes produced HOOK_MARKER and SIGNATURE_REJECTED, and the mixed duplicate still called the first function. Quoted/postponed annotations and unresolved-name refusals have regression cases.

    This is not a released fix. Resources/prompts are unchanged, a still-unresolved name remains an InvalidSignature, and list_tools still refuses while any pending tool cannot be built. The existing models-first / add_tool-after-models workarounds remain valid.

    Would a narrow decorator-only deferral like this fit the intended registration contract, or would you prefer a common server-start build phase before pursuing a 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