Skip to content

Commit cafe8f3

Browse files
committed
Resolve type hints for callable-object tools in resolver detection
find_resolved_parameters called typing.get_type_hints on the callable directly, which raises for a callable instance (an object with __call__), breaking tool registration for callable objects. Resolve hints off __call__ and tolerate unresolvable hints, mirroring find_context_parameter.
1 parent e110093 commit cafe8f3

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

src/mcp/server/mcpserver/resolve.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,30 @@ def __init__(self, fn: Callable[..., Any], params: dict[str, _ParamPlan], is_asy
8585
self.is_async = is_async
8686

8787

88+
def _type_hints(fn: Callable[..., Any]) -> dict[str, Any]:
89+
"""Resolve type hints for a function or a callable object.
90+
91+
`typing.get_type_hints` raises on a callable *instance*; fall back to its
92+
`__call__`. Returns an empty mapping when hints cannot be resolved, matching
93+
`find_context_parameter`'s tolerance so callables without annotations (or with
94+
unresolvable ones) simply have no resolved parameters.
95+
"""
96+
target = fn if inspect.isroutine(fn) else getattr(type(fn), "__call__", fn)
97+
try:
98+
return typing.get_type_hints(target, include_extras=True)
99+
except Exception:
100+
return {}
101+
102+
88103
def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve, bool]]:
89104
"""Find parameters of `fn` annotated `Annotated[_, Resolve(...)]`.
90105
91106
Returns a mapping of parameter name to `(Resolve, wants_union)`, where
92107
`wants_union` is True when the annotated type is an `ElicitationResult` member
93108
(the consumer wants the full outcome rather than the unwrapped model).
94109
"""
95-
hints = typing.get_type_hints(fn, include_extras=True)
96110
resolved: dict[str, tuple[Resolve, bool]] = {}
97-
for name, annotation in hints.items():
111+
for name, annotation in _type_hints(fn).items():
98112
if get_origin(annotation) is not Annotated:
99113
continue
100114
type_arg, *metadata = get_args(annotation)
@@ -130,7 +144,7 @@ def analyze(fn: Callable[..., Any], stack: tuple[int, ...]) -> None:
130144
if key in plans:
131145
return
132146

133-
hints = typing.get_type_hints(fn, include_extras=True)
147+
hints = _type_hints(fn)
134148
sig = inspect.signature(fn)
135149
params: dict[str, _ParamPlan] = {}
136150
nested: list[Callable[..., Any]] = []

tests/server/mcpserver/test_resolve.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
Resolve,
1818
)
1919
from mcp.server.mcpserver.exceptions import InvalidSignature
20+
from mcp.server.mcpserver.resolve import find_resolved_parameters
2021
from mcp.server.mcpserver.tools.base import Tool
2122
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
2223

@@ -254,6 +255,14 @@ async def tool(value: Annotated[Login, Resolve(a)]) -> str:
254255
Tool.from_function(tool)
255256

256257

258+
def test_find_resolved_parameters_tolerates_unresolvable_hints():
259+
def fn(x: int) -> int:
260+
return x # pragma: no cover
261+
262+
fn.__annotations__["x"] = "DoesNotExist"
263+
assert find_resolved_parameters(fn) == {}
264+
265+
257266
def test_unresolvable_resolver_param_raises_at_registration():
258267
async def login(mystery: int) -> Login:
259268
return Login(username="x") # pragma: no cover

0 commit comments

Comments
 (0)