From 2ed8c3ffc25ac93613cf74d247657f2d8b90c507 Mon Sep 17 00:00:00 2001 From: Hashwanth Sutharapu Date: Wed, 11 Feb 2026 23:43:04 -0800 Subject: [PATCH] fix: handle callable class instances in find_context_parameter() When registering a callable class instance as an MCP tool via FastMCP.add_tool(), the ctx: Context parameter was incorrectly exposed as an externally visible tool parameter instead of being injected by the framework. Root cause: typing.get_type_hints() was called on the class instance rather than its __call__ method, so Context type hints were not resolved. Fix: detect non-function/non-method callables and target their __call__ method, mirroring the existing pattern in _is_async_callable(). Fixes #1974 --- .../mcpserver/utilities/context_injection.py | 10 +++- tests/server/mcpserver/test_tool_manager.py | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/utilities/context_injection.py b/src/mcp/server/mcpserver/utilities/context_injection.py index 9cba83e86..e44a94e81 100644 --- a/src/mcp/server/mcpserver/utilities/context_injection.py +++ b/src/mcp/server/mcpserver/utilities/context_injection.py @@ -22,9 +22,17 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None: """ from mcp.server.mcpserver.server import Context + # Handle callable class instances by using __call__ method, + # since typing.get_type_hints() doesn't introspect __call__ + # on class instances. + target = fn + if not (inspect.isfunction(fn) or inspect.ismethod(fn)): + if callable(fn) and hasattr(fn, "__call__"): + target = fn.__call__ + # Get type hints to properly resolve string annotations try: - hints = typing.get_type_hints(fn) + hints = typing.get_type_hints(target) except Exception: # pragma: lax no cover # If we can't resolve type hints, we can't find the context parameter return None diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 550bba50a..b81d1cade 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -413,6 +413,58 @@ def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: with pytest.raises(ToolError, match="Error executing tool tool_with_context"): await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) + def test_context_parameter_detection_callable_class(self): + """Test that context parameters are detected in callable class instances.""" + + class MyTool: + def __init__(self): + self.__name__ = "my_tool" + + def __call__(self, x: int, ctx: Context[ServerSessionT, None]) -> str: # pragma: no cover + return str(x) + + manager = ToolManager() + tool = manager.add_tool(MyTool()) + assert tool.context_kwarg == "ctx" + assert "ctx" not in json.dumps(tool.parameters) + assert "Context" not in json.dumps(tool.parameters) + + def test_context_parameter_detection_async_callable_class(self): + """Test that context parameters are detected in async callable class instances.""" + + class MyAsyncTool: + def __init__(self): + self.__name__ = "my_async_tool" + + async def __call__(self, x: int, ctx: Context[ServerSessionT, None]) -> str: # pragma: no cover + return str(x) + + manager = ToolManager() + tool = manager.add_tool(MyAsyncTool()) + assert tool.context_kwarg == "ctx" + assert tool.is_async is True + assert "ctx" not in json.dumps(tool.parameters) + + @pytest.mark.anyio + async def test_context_injection_callable_class(self): + """Test that context is properly injected in callable class tools.""" + + class MyTool: + def __init__(self): + self.__name__ = "my_tool" + + def __call__(self, x: int, ctx: Context[ServerSessionT, None]) -> str: + assert isinstance(ctx, Context) + return str(x) + + manager = ToolManager() + manager.add_tool(MyTool()) + + mcp = MCPServer() + ctx = mcp.get_context() + result = await manager.call_tool("my_tool", {"x": 42}, context=ctx) + assert result == "42" + class TestToolAnnotations: def test_tool_annotations(self):