Repository navigation
@app.tool() raises NameError for parameter/return types defined later in the module on Python 3.14+ (PEP 649/749 deferred annotations) #3668
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
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 withInvalidSignature: 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, andcall_toolvalidates and returns correctly. So deferring the build is enough. - Quoting does not help:
q: "Query"givesInvalidSignatureon 3.14. So doesfrom __future__ import annotationson 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 raiseInvalidSignature(step 4).
Related but not duplicates:
- Tool context kwarg detection not compatible with string-valued/__future__ annotations #1129 (context kwarg detection with string annotations)
- wrapped tools using annotations fail #1391 (wrapped tools +
__future__annotations, wrong globals) - find_context_parameter treats a Context return annotation as a parameter #3298 (
find_context_parametertreats thereturnhint as a param)
Suggested fix (open to maintainers' preference):
- Defer schema building for tools, and the same for resources/prompts.
@app.tool()would record the function and options. TheTool(func_metadata, context/resolve detection) would be built on firstlist_tools/call_tool, or at server start, and cached. AnyNameErrorthen surfaces at that point asInvalidSignaturenaming the tool and the missing name. Registration-time validation that doesn't need types (name, lambda check, duplicates) can stay eager. - Independently, at
resolve.py:229/:376andserver.py:873, only parameter names are used. On 3.14+ useinspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF); on older versions,inspect.signature(fn)plusfn.__code__-based names, or a try/except. Then a bare NameError can't escape from those sites. - Stop silently swallowing unresolvable hints in
find_context_parameterand_type_hints. Once building is deferred, they'll resolve. If they still can't, raisingInvalidSignatureis safer than silently not injectingContextor droppingResolve(...). - 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
epistemedeus commentedon Oct 10, 2026
I reproduced the supplied example on main
91941ed4with Python 3.14.8 and prepared a tools-only candidate: patch, reproduction and application notes.It defers only a missing annotation name during
@toolregistration, then builds the recorded function on its first list/get/call. Directadd_toolremains eager. Publicadd_toolsubclass 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_MARKERandSIGNATURE_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, andlist_toolsstill 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?
fix(mcpserver): stop a bare NameError escaping tool registration on 3.14
insuffer commentedon Oct 12, 2026
Confirming the repro on
mainwith Python 3.14.4, both forms:q: Query) -> bareNameError: name 'Query' is not definedfrom insideinspect.signature, at the three sites that only want the parameter namesq: "Query") orfrom __future__ import annotations->InvalidSignaturefromfunc_metadata, whose message did not name the missing typeI opened #3686 for the narrower half of this, and want to be explicit about the split so it can be judged on its own:
What it does — the three name-only sites (
resolve.pyfind_resolved_parametersandbuild_resolver_plans,server.py's resource decorator) read names through a newsignature_parameters()that asks forFormat.FORWARDREFon 3.14+ and keeps the plain call on older Pythons, where string annotations are already unevaluated. Registration then reaches the step that genuinely needs the types and fails with theInvalidSignatureit already produced for a quoted annotation, now naming the unresolved type:What it deliberately does not do — defer the schema build so the repro's tool actually registers. That is the outcome you listed first, and it is a change to the
Toollifecycle rather than to these three call sites, so I left it to your preference rather than pick a design for you. It would be additive on top of this.Two things I noticed while in there that you may want to weigh separately:
find_context_parameterand_type_hintsswallow theNameErrortoday, which is what makes actx: Contextparameter silently uninjected on 3.14 when the types are not resolvable yet; andserver.py:873'ssigwas used for defaults further down, so a naive swap there breaks three existing tests.Tests: three, built with
execand gated on 3.14+ (an unquoted undefined name raises where the function is defined on older Pythons and would break module import). All three fail on the unpatched source with theNameErrorabove;tests/server/mcpserver/is 644 passed with them, 641 without.fix(mcpserver): stop a bare NameError escaping tool registration on 3.14
ao3575911 commentedon Oct 13, 2026
Thanks both. I'd go with building the schema when the server starts, not a decorator-only deferral. On 3.14 the whole point of PEP 649 is that
q: Queryis valid beforeQueryis defined, so the tool should register, not just fail with a clearer error. #3686 is a good first step for the error message, but I'd treat it as complementary rather than the fix. Building on first list/get/call, withadd_toolstaying eager, sounds right to me.