Skip to content

Commit 59f4c8d

Browse files
committed
Tolerate lazily-evaluated type aliases in the input_required return check
Accessing `__value__` on a PEP 695 `type X = ...` alias evaluates it, so an alias naming TYPE_CHECKING-only imports raised NameError at registration for resolver-using tools that registered fine before the check existed. Such an alias declares no arm the check can see; the in-call guard still covers a body that returns an InputRequiredResult. Also pin the neighboring shapes that already work: a subscripted generic alias forwards `__value__` to its origin and is rejected, and a parameterized builtin generic return registers on every supported Python.
1 parent 5dd78a9 commit 59f4c8d

2 files changed

Lines changed: 48 additions & 4 deletions

File tree

src/mcp/server/mcpserver/resolve.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,15 @@ def _has_input_required_arm(annotation: Any) -> bool:
189189
"""Walk an annotation's arms through `Annotated`, type aliases, and unions."""
190190
if get_origin(annotation) is Annotated:
191191
return _has_input_required_arm(get_args(annotation)[0])
192-
# A `type X = ...` / `TypeAliasType` alias carries its target on `__value__`.
193-
value = getattr(annotation, "__value__", None)
192+
# A `type X = ...` / `TypeAliasType` alias carries its target on `__value__` (a
193+
# subscripted alias forwards the attribute to its origin). The access evaluates
194+
# a PEP 695 alias lazily, so an alias naming things unavailable at runtime
195+
# (TYPE_CHECKING-only imports) raises NameError; such an alias declares no arm
196+
# this check can see, and the in-call guard in `Tool.run` still covers it.
197+
try:
198+
value = getattr(annotation, "__value__", None)
199+
except NameError:
200+
return False
194201
if value is not None:
195202
return _has_input_required_arm(value)
196203
if _is_union(annotation):

tests/server/mcpserver/test_resolve.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from collections.abc import Callable
55
from datetime import datetime
6-
from typing import Annotated, Any, Literal
6+
from typing import Annotated, Any, Literal, TypeVar
77

88
import anyio
99
import pytest
@@ -62,8 +62,20 @@ class Restock(BaseModel):
6262
needed: bool
6363

6464

65-
# The `type X = ...` spelling of an InputRequiredResult-bearing return annotation.
65+
# The `type X = ...` spelling of an InputRequiredResult-bearing return annotation,
66+
# bare and generic (a subscripted alias forwards `__value__` to its origin).
6667
IRRAlias = TypeAliasType("IRRAlias", InputRequiredResult | str)
68+
T_alias = TypeVar("T_alias")
69+
IRRAliasGeneric = TypeAliasType("IRRAliasGeneric", InputRequiredResult | T_alias, type_params=(T_alias,))
70+
71+
72+
class _UnevaluableAlias:
73+
"""Stand-in for `type X = GhostType | str` whose names exist only under
74+
TYPE_CHECKING: accessing `__value__` evaluates the alias and raises."""
75+
76+
@property
77+
def __value__(self) -> Any:
78+
raise NameError("name 'GhostType' is not defined")
6779

6880

6981
class Handle(BaseModel):
@@ -1711,6 +1723,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
17111723
Annotated[InputRequiredResult | str, "meta"],
17121724
str | Annotated[InputRequiredResult, "meta"], # Annotated as a union member
17131725
IRRAlias, # `type X = ...` alias
1726+
IRRAliasGeneric[str], # subscripted generic alias
17141727
],
17151728
)
17161729
def test_tool_combining_resolvers_with_input_required_return_is_rejected(annotation: Any):
@@ -1736,6 +1749,30 @@ async def manual() -> InputRequiredResult:
17361749
assert returns_input_required(manual)
17371750

17381751

1752+
def test_unevaluable_alias_and_parameterized_generics_declare_no_arm():
1753+
# A `type X = ...` alias is evaluated lazily, so one naming TYPE_CHECKING-only
1754+
# imports raises NameError on `__value__` access: it declares no arm the check
1755+
# can see and must not break registration (the in-call guard still covers a
1756+
# body that returns an InputRequiredResult anyway). A parameterized generic
1757+
# return is never the InputRequiredResult class either.
1758+
mcp = MCPServer(name="RegistrationTolerance")
1759+
1760+
async def lookup(ctx: Context) -> Login:
1761+
return Login(username="x") # pragma: no cover - only registration is exercised
1762+
1763+
async def lazy(login: Annotated[Login, Resolve(lookup)]):
1764+
raise NotImplementedError # pragma: no cover
1765+
1766+
lazy.__annotations__["return"] = _UnevaluableAlias()
1767+
assert not returns_input_required(lazy)
1768+
1769+
@mcp.tool()
1770+
async def listy(login: Annotated[Login, Resolve(lookup)]) -> list[str]:
1771+
raise NotImplementedError # pragma: no cover
1772+
1773+
assert not returns_input_required(listy)
1774+
1775+
17391776
@pytest.mark.anyio
17401777
async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error():
17411778
# The annotated form of this combination is rejected at registration; a body

0 commit comments

Comments
 (0)