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):
- 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.
- 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.
- 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(...).
- 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)
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:MCPServer.tool()builds theTooleagerly inside the decorator. The first thing it does isinspect.signature(fn)infind_resolved_parameters, which defaults toannotation_format=Format.VALUE. That forcesfn.__annotate__to run whileQuerydoesn't exist yet, so registration crashes with a bareNameError:Expected: either the tool registers (resolving annotations once the module has finished defining the types), or a clear
InvalidSignatureerror that names the tool and the unresolved name.Code path (v2.3.0; these files are byte-identical on
main@ 91941ed):tools/base.py:89find_context_parameter(fn)→utilities/context_injection.py:27typing.get_type_hints(fn)None. So actx: Contextparameter would be silently missed.resolve.py:209-212_type_hints()→typing.get_type_hints(..., include_extras=True){}. SoAnnotated[_, Resolve(...)]markers would be silently dropped.resolve.py:229inspect.signature(fn).parametersutilities/func_metadata.py:322inspect.signature(func, eval_str=True)InvalidSignature. This is what you hit if step 3 is patched, and with quoted annotations orfrom __future__ import annotations. The inline comment there already suggestsmodel_rebuildlater.(The same eager pattern is at
resolve.py:376and in the resource decorator atserver.py:873.)What I verified locally:
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.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.q: "Query"givesInvalidSignatureon 3.14. So doesfrom __future__ import annotationson 3.13.5. Only defining the types before the decorated function works today.NameError(step 3), while quoted or__future__annotations raiseInvalidSignature(step 4).Related but not duplicates:
__future__annotations, wrong globals)find_context_parametertreats thereturnhint as a param)Suggested fix (open to maintainers' preference):
@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.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.find_context_parameterand_type_hints. Once building is deferred, they'll resolve. If they still can't, raisingInvalidSignatureis safer than silently not injectingContextor droppingResolve(...).Workaround: define all models used in tool signatures above the
@app.tool()function, or callapp.add_tool(fn)after the models are defined. Quoting the annotation does not work.Example Code
Full traceback (fresh run, Python 3.14.8):
The traceback is identical on 3.14.8 free-threaded and 3.15.0rc3 (only the stdlib line numbers differ).
Python & MCP Python SDK