Skip to content

Commit 2d5225b

Browse files
committed
fix(mcpserver): stop a bare NameError escaping tool registration on 3.14
Since Python 3.14 (PEP 649/749) annotations are evaluated lazily, so a tool may legally name a type the module defines further down: @app.tool() def search(q: Query) -> list[Hit]: ... class Query(BaseModel): ... `MCPServer.tool()` builds the `Tool` eagerly, and three of its steps call `inspect.signature(fn)` while only needing the parameter *names*. The default `annotation_format` evaluates `__annotate__`, so registration died with a bare `NameError: name 'Query' is not defined` raised from inside `inspect`. Those steps now read names through `signature_parameters()`, which asks for `Format.FORWARDREF` on 3.14+ and keeps the plain call on older Pythons, where string annotations are already returned unevaluated. Registration then reaches the step that genuinely needs the types and fails there with the `InvalidSignature` it already produced for a quoted annotation -- now naming the unresolved type, so the error says which name to move above the decorated function instead of only that something could not be evaluated. Deferring the whole schema build so the tool would register and resolve on first use is the other half of the issue and is left to the maintainers' preference; this change makes the failure consistent and actionable either way. Refs #3668
1 parent 4b5fe5a commit 2d5225b

7 files changed

Lines changed: 123 additions & 12 deletions

File tree

‎src/mcp/server/mcpserver/resolve.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
8282
from mcp.server.request_state import compact_json
8383
from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools
84-
from mcp.shared._callable_inspection import is_async_callable
84+
from mcp.shared._callable_inspection import is_async_callable, signature_parameters
8585
from mcp.shared.exceptions import MCPError
8686
from mcp.shared.message import ServerMessageMetadata
8787

@@ -226,7 +226,7 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve,
226226
"""
227227
hints = _type_hints(fn)
228228
resolved: dict[str, tuple[Resolve, bool]] = {}
229-
for name in inspect.signature(fn).parameters:
229+
for name in signature_parameters(fn):
230230
annotation = hints.get(name)
231231
if get_origin(annotation) is not Annotated:
232232
# A `Resolve` marker is only honored at the top level; flag (rather than
@@ -373,10 +373,9 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
373373
wire_key = base if seen == 0 else f"{base}#{seen}"
374374

375375
hints = _type_hints(fn)
376-
sig = inspect.signature(fn)
377376
params: dict[str, _ParamPlan] = {}
378377
nested: list[Callable[..., Any]] = []
379-
for param_name in sig.parameters:
378+
for param_name in signature_parameters(fn):
380379
annotation = hints.get(param_name)
381380
if annotation is not None and _is_context_annotation(annotation):
382381
params[param_name] = _ParamPlan("context")

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
)
101101
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
102102
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
103+
from mcp.shared._callable_inspection import signature_parameters
103104
from mcp.shared.exceptions import MCPError
104105
from mcp.shared.uri_template import UriTemplate
105106

@@ -870,9 +871,12 @@ async def get_weather(city: str) -> str:
870871
uri_params = set(parsed.variable_names)
871872

872873
def decorator(fn: _CallableT) -> _CallableT:
873-
sig = inspect.signature(fn)
874874
context_param = find_context_parameter(fn)
875-
func_params = {p for p in sig.parameters.keys() if p != context_param}
875+
# Names only: on Python 3.14+ evaluating the annotations here would
876+
# raise for a type the module has not finished defining. The one
877+
# place below that needs a default reads it from the same object.
878+
parameters = signature_parameters(fn)
879+
func_params = {p for p in parameters if p != context_param}
876880

877881
# Template/static is decided purely by the URI: variables
878882
# present means template, none means static.
@@ -889,9 +893,7 @@ def decorator(fn: _CallableT) -> _CallableT:
889893
# one, the author only finds out on the first request that
890894
# omits it, as an opaque internal error.
891895
missing_defaults = sorted(
892-
name
893-
for name in parsed.query_variable_names
894-
if sig.parameters[name].default is inspect.Parameter.empty
896+
name for name in parsed.query_variable_names if parameters[name].default is inspect.Parameter.empty
895897
)
896898
if missing_defaults:
897899
raise ValueError(

‎src/mcp/server/mcpserver/utilities/func_metadata.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,14 @@ def func_metadata(
320320
"""
321321
try:
322322
sig = inspect.signature(func, eval_str=True)
323-
except NameError as e: # pragma: no cover
323+
except NameError as e:
324324
# This raise could perhaps be skipped, and we (MCPServer) just call
325325
# model_rebuild right before using it 🤷
326-
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
326+
# `e` names the unresolved annotation, which is the part the caller can
327+
# act on: on Python 3.14+ (PEP 649) a type defined below the decorated
328+
# function is simply not there yet, and "name 'Query' is not defined"
329+
# says so where a bare "unable to evaluate" does not.
330+
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}: {e}") from e
327331
params = sig.parameters
328332
dynamic_pydantic_model_params: dict[str, Any] = {}
329333
for param in params.values():

‎src/mcp/shared/_callable_inspection.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88

99
import functools
1010
import inspect
11-
from collections.abc import Awaitable, Callable
11+
import sys
12+
from collections.abc import Awaitable, Callable, Mapping
1213
from typing import Any, TypeGuard, TypeVar, overload
1314

15+
if sys.version_info >= (3, 14): # pragma: no cover - version-dependent import
16+
import annotationlib
17+
1418
T = TypeVar("T")
1519

1620
AwaitableCallable = Callable[..., Awaitable[T]]
@@ -31,3 +35,19 @@ def is_async_callable(obj: Any) -> Any:
3135
return inspect.iscoroutinefunction(obj) or (
3236
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
3337
)
38+
39+
40+
def signature_parameters(fn: Callable[..., Any]) -> Mapping[str, inspect.Parameter]:
41+
"""Parameters of `fn` by name, without evaluating its annotations.
42+
43+
Callers that only need the names must not force evaluation: on Python 3.14+
44+
(PEP 649) annotations are evaluated lazily through `__annotate__`, and
45+
`inspect.signature` evaluates them by default, so a parameter whose type the
46+
module has not finished defining raises a bare `NameError` from a call that
47+
never wanted the type. `Format.FORWARDREF` leaves them unevaluated; on older
48+
Pythons string annotations are already returned unevaluated, so the plain
49+
call is safe there.
50+
"""
51+
if sys.version_info >= (3, 14):
52+
return inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF).parameters
53+
return inspect.signature(fn).parameters

‎tests/server/mcpserver/test_func_metadata.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,3 +1520,23 @@ def fn() -> StepA | StepB: ... # pragma: no branch
15201520

15211521
meta = func_metadata(fn)
15221522
assert meta.output_schema is None
1523+
1524+
1525+
def test_unresolvable_annotation_names_the_callable_and_the_missing_type():
1526+
"""A forward-referenced type that does not exist yet has to say which one.
1527+
1528+
On Python 3.14+ (PEP 649) annotations are evaluated lazily, so a type defined
1529+
below the decorated function is simply not there yet; the caller can only act
1530+
on the error if it names the missing type. On older Pythons the same message
1531+
comes from a quoted annotation. See #3668.
1532+
"""
1533+
1534+
# The undefined name is the point of the test.
1535+
def fn(q: "NotDefinedYet") -> str: ... # noqa: F821 # pragma: no branch
1536+
1537+
with pytest.raises(InvalidSignature) as exc_info:
1538+
func_metadata(fn)
1539+
1540+
message = str(exc_info.value)
1541+
assert "'fn'" in message
1542+
assert "NotDefinedYet" in message

‎tests/server/mcpserver/test_resolve.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import sys
56
from collections.abc import Callable
67
from datetime import datetime
78
from typing import Annotated, Any, Literal, TypeVar, cast
@@ -2875,3 +2876,23 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
28752876

28762877
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
28772878
assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}}
2879+
2880+
2881+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="PEP 649 lazy annotations are 3.14+")
2882+
def test_find_resolved_parameters_reads_names_without_evaluating_annotations():
2883+
"""Only the names are needed here, so an unresolvable annotation must not raise.
2884+
2885+
On Python 3.14+ (PEP 649) annotations are evaluated lazily and
2886+
`inspect.signature` evaluates them by default, so a parameter whose type the
2887+
module has not finished defining raises a bare `NameError` from a call that
2888+
only wanted the names. #3668.
2889+
"""
2890+
from mcp.server.mcpserver.resolve import find_resolved_parameters
2891+
2892+
# Built with exec so the undefined name is never statically visible: on older
2893+
# Pythons an unquoted undefined annotation raises where the function is
2894+
# defined, which would make this module fail to import.
2895+
namespace: dict[str, Any] = {}
2896+
exec("def fn(q: NotDefinedYet) -> str: ...", namespace) # noqa: S102
2897+
2898+
assert find_resolved_parameters(namespace["fn"]) == {}

‎tests/server/mcpserver/test_server.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import logging
3+
import sys
34
from collections.abc import AsyncIterator
45
from contextlib import asynccontextmanager
56
from dataclasses import dataclass
@@ -57,6 +58,7 @@
5758
from mcp.server.context import ServerRequestContext
5859
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity, Resolve, ResourceSecurity
5960
from mcp.server.mcpserver.exceptions import (
61+
InvalidSignature,
6062
ResourceError,
6163
ResourceNotFoundError,
6264
ToolError,
@@ -3318,3 +3320,46 @@ async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) ->
33183320
pass # pragma: no cover - the refusal precedes the stream
33193321
assert exc_info.value.error.code == INVALID_REQUEST
33203322
assert exc_info.value.error.message == "not permitted to watch the requested resources"
3323+
3324+
3325+
# The issue's repro: a tool whose parameter and return types are defined *below*
3326+
# it, which PEP 649 (3.14+) makes legal without quoting. Built with exec so the
3327+
# undefined names are never statically visible -- on older Pythons an unquoted
3328+
# undefined annotation raises where the function is defined, which would make
3329+
# this module fail to import.
3330+
_FORWARD_REF_TOOL_SOURCE = """
3331+
from pydantic import BaseModel
3332+
3333+
app = MCPServer("repro")
3334+
3335+
3336+
@app.tool()
3337+
def search(q: Query) -> list[Hit]:
3338+
\"\"\"Search.\"\"\"
3339+
return [Hit(id=1)]
3340+
3341+
3342+
class Query(BaseModel):
3343+
text: str
3344+
3345+
3346+
class Hit(BaseModel):
3347+
id: int
3348+
"""
3349+
3350+
3351+
@pytest.mark.skipif(sys.version_info < (3, 14), reason="PEP 649 lazy annotations are 3.14+")
3352+
async def test_tool_with_types_defined_below_it_fails_with_a_named_error():
3353+
"""Registration must not leak a bare NameError from inside inspect.signature.
3354+
3355+
It raises `InvalidSignature` naming the tool and the type that could not be
3356+
resolved yet, the same error a quoted annotation already produced. #3668.
3357+
"""
3358+
namespace: dict[str, Any] = {"MCPServer": MCPServer}
3359+
3360+
with pytest.raises(InvalidSignature) as exc_info:
3361+
exec(_FORWARD_REF_TOOL_SOURCE, namespace) # noqa: S102
3362+
3363+
message = str(exc_info.value)
3364+
assert "'search'" in message
3365+
assert "Query" in message

0 commit comments

Comments
 (0)