diff --git a/pyproject.toml b/pyproject.toml index a4c59a58d..ac8735405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "pytest>=8.3.3", "pytest-asyncio>=0.23.5", "pytest-cov>=6.1.1", + "pytest-env>=1.1.5", "pytest-flakefinder", "pytest-report>=0.2.1", "pytest-timeout>=2.4.0", @@ -84,6 +85,11 @@ asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" filterwarnings = [] timeout = 3 +env = [ + "FASTMCP_TEST_MODE=1", + 'D:FASTMCP_LOG_LEVEL=DEBUG', + 'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0', +] [tool.pyright] include = ["src", "tests"] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 26698f5d6..0b870438e 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -97,7 +97,7 @@ class Prompt(BaseModel): """ from fastmcp.server.context import Context - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") @@ -109,6 +109,12 @@ class Prompt(BaseModel): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as prompts") + description = description or fn.__doc__ + + # if the fn is a callable class, we need to get the __call__ method from here out + if not inspect.isfunction(fn): + fn = fn.__call__ + type_adapter = get_cached_typeadapter(fn) parameters = type_adapter.json_schema() @@ -139,7 +145,7 @@ class Prompt(BaseModel): return cls( name=func_name, - description=description or fn.__doc__, + description=description, arguments=arguments, fn=fn, tags=tags or set(), diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index eaf0cfe7b..9e7984a67 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -14,7 +14,6 @@ from pydantic import ( BaseModel, BeforeValidator, Field, - TypeAdapter, field_validator, validate_call, ) @@ -25,6 +24,7 @@ from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import ( _convert_set_defaults, find_kwarg_by_type, + get_cached_typeadapter, ) @@ -97,7 +97,7 @@ class ResourceTemplate(BaseModel): """Create a template from a function.""" from fastmcp.server.context import Context - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") @@ -148,8 +148,13 @@ class ResourceTemplate(BaseModel): f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}" ) - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + description = description or fn.__doc__ or "" + + if not inspect.isfunction(fn): + fn = fn.__call__ + + type_adapter = get_cached_typeadapter(fn) + parameters = type_adapter.json_schema() # compress the schema prune_params = [context_kwarg] if context_kwarg else None @@ -161,7 +166,7 @@ class ResourceTemplate(BaseModel): return cls( uri_template=uri_template, name=func_name, - description=description or fn.__doc__ or "", + description=description, mime_type=mime_type or "text/plain", fn=fn, parameters=parameters, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index c1dbf5447..662b0db6f 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -29,6 +29,16 @@ class Settings(BaseSettings): test_mode: bool = False log_level: LOG_LEVEL = "INFO" + enable_rich_tracebacks: Annotated[ + bool, + Field( + description=inspect.cleandoc( + """ + If True, will use rich tracebacks for logging. + """ + ) + ), + ] = True client_raise_first_exceptiongroup_error: Annotated[ bool, @@ -82,7 +92,9 @@ class Settings(BaseSettings): """Finalize the settings.""" from fastmcp.utilities.logging import configure_logging - configure_logging(self.log_level) + configure_logging( + self.log_level, enable_rich_tracebacks=self.enable_rich_tracebacks + ) return self diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 73b84d76f..4f22ca4f6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -69,13 +69,17 @@ class Tool(BaseModel): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as tools") - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") func_doc = description or fn.__doc__ or "" + # if the fn is a callable class, we need to get the __call__ method from here out + if not inspect.isfunction(fn): + fn = fn.__call__ + type_adapter = get_cached_typeadapter(fn) schema = type_adapter.json_schema() diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index d30074190..d30eba0dc 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -22,6 +22,7 @@ def get_logger(name: str) -> logging.Logger: def configure_logging( level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", logger: logging.Logger | None = None, + enable_rich_tracebacks: bool = True, ) -> None: """ Configure logging for FastMCP. @@ -30,11 +31,15 @@ def configure_logging( logger: the logger to configure level: the log level to use """ + if logger is None: logger = logging.getLogger("FastMCP") # Only configure the FastMCP logger namespace - handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True) + handler = RichHandler( + console=Console(stderr=True), + rich_tracebacks=enable_rich_tracebacks, + ) formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index c77ea0b88..a0cda7b2d 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -47,6 +47,30 @@ class TestRenderPrompt: ) ] + async def test_callable_object(self): + class MyPrompt: + def __call__(self, name: str) -> str: + return f"Hello, {name}!" + + prompt = Prompt.from_function(MyPrompt()) + assert await prompt.render(arguments=dict(name="World")) == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + + async def test_async_callable_object(self): + class MyPrompt: + async def __call__(self, name: str) -> str: + return f"Hello, {name}!" + + prompt = Prompt.from_function(MyPrompt()) + assert await prompt.render(arguments=dict(name="World")) == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + async def test_fn_with_invalid_kwargs(self): async def fn(name: str, age: int = 30) -> str: return f"Hello, {name}! You're {age} years old." diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index d44dde0bf..e00aba3e0 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -141,6 +141,8 @@ class TestPromptManager: assert prompts["fn1"] == prompt1 assert prompts["fn2"] == prompt2 + +class TestRenderPrompt: async def test_render_prompt(self): """Test rendering a prompt.""" @@ -177,6 +179,48 @@ class TestPromptManager: ) ] + async def test_render_prompt_callable_object(self): + """Test rendering a prompt with a callable object.""" + + class MyPrompt: + """A callable object that can be used as a prompt.""" + + def __call__(self, name: str) -> str: + """ignore this""" + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(MyPrompt()) + manager.add_prompt(prompt) + result = await manager.render_prompt("MyPrompt", arguments={"name": "World"}) + assert result.description == "A callable object that can be used as a prompt." + assert result.messages == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + + async def test_render_prompt_callable_object_async(self): + """Test rendering a prompt with a callable object.""" + + class MyPrompt: + """A callable object that can be used as a prompt.""" + + async def __call__(self, name: str) -> str: + """ignore this""" + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(MyPrompt()) + manager.add_prompt(prompt) + result = await manager.render_prompt("MyPrompt", arguments={"name": "World"}) + assert result.description == "A callable object that can be used as a prompt." + assert result.messages == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + async def test_render_unknown_prompt(self): """Test rendering a non-existent prompt.""" manager = PromptManager() diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 089b4ab89..5bb3846a7 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -368,6 +368,31 @@ class TestResourceTemplate: ) assert template.uri_template == "test://{x}/{y}/{z}" + async def test_callable_object_as_template(self): + """Test that a callable object can be used as a template.""" + + class MyTemplate: + """This is my template""" + + def __call__(self, x: str) -> str: + """ignore this""" + return f"X was {x}" + + template = ResourceTemplate.from_function( + fn=MyTemplate(), + uri_template="test://{x}", + name="test", + ) + + resource = await template.create_resource( + "test://foo", + {"x": "foo"}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "X was foo" + class TestMatchUriTemplate: """Test match_uri_template function.""" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 2e739c2b8..d7c7577c5 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -709,6 +709,21 @@ class TestToolContextInjection: assert len(tools) == 1 # Note: MCPTool from the client API doesn't expose tags + async def test_callable_object_with_context(self): + """Test that a callable object can be used as a tool with context.""" + mcp = FastMCP() + + class MyTool: + async def __call__(self, x: int, ctx: Context) -> int: + return x + int(ctx.request_id) + + mcp.add_tool(MyTool()) + + async with Client(mcp) as client: + result = await client.call_tool("MyTool", {"x": 2}) + assert isinstance(result[0], TextContent) + assert result[0].text == "4" + class TestResource: async def test_text_resource(self): @@ -1096,6 +1111,20 @@ class TestResourceTemplateContext: assert isinstance(result[0], TextResourceContents) assert result[0].text.startswith("Resource template: test 2") + async def test_resource_template_context_with_callable_object(self): + mcp = FastMCP() + + class MyResource: + def __call__(self, param: str, ctx: Context) -> str: + return f"Resource template: {param} {ctx.request_id}" + + mcp.add_resource_fn(MyResource(), uri="resource://{param}") + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://test")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text.startswith("Resource template: test 2") + class TestPrompts: """Test prompt functionality in FastMCP server.""" @@ -1298,3 +1327,20 @@ class TestPromptContext: assert len(result.messages) == 1 message = result.messages[0] assert message.role == "user" + + async def test_prompt_context_with_callable_object(self): + mcp = FastMCP() + + class MyPrompt: + def __call__(self, name: str, ctx: Context) -> str: + return f"Hello, {name}! {ctx.request_id}" + + mcp.add_prompt(MyPrompt(), name="my_prompt") + + async with Client(mcp) as client: + result = await client.get_prompt("my_prompt", {"name": "World"}) + assert len(result.messages) == 1 + message = result.messages[0] + assert message.role == "user" + assert isinstance(message.content, TextContent) + assert message.content.text == "Hello, World! 2" diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 89f1c4822..141035534 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -21,6 +21,7 @@ class TestToolFromFunction: assert tool.name == "add" assert tool.description == "Add two numbers." + assert len(tool.parameters["properties"]) == 2 assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" @@ -37,6 +38,36 @@ class TestToolFromFunction: assert tool.description == "Fetch data from URL." assert tool.parameters["properties"]["url"]["type"] == "string" + def test_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + tool = Tool.from_function(Adder()) + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + + def test_async_callable_object(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + tool = Tool.from_function(Adder()) + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + def test_pydantic_model_function(self): """Test registering a function that takes a Pydantic model.""" diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 8a4d6c556..5cc3aa33e 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -71,6 +71,44 @@ class TestAddTools: assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] assert "flag" in tool.parameters["properties"] + def test_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + + tool = manager.get_tool("Adder") + assert tool is not None + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + + def test_async_callable_object(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + + tool = manager.get_tool("Adder") + assert tool is not None + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + async def test_tool_with_image_return(self): def image_tool(data: bytes) -> Image: return Image(data=data) @@ -303,6 +341,40 @@ class TestCallTools: assert result[0].text == "10" assert json.loads(result[0].text) == 10 + async def test_call_tool_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + result = await manager.call_tool("Adder", {"x": 1, "y": 2}) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "3" + assert json.loads(result[0].text) == 3 + + async def test_call_tool_callable_object_async(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + result = await manager.call_tool("Adder", {"x": 1, "y": 2}) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "3" + assert json.loads(result[0].text) == 3 + async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: """Add two numbers.""" diff --git a/uv.lock b/uv.lock index 8613b7367..df4d56995 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-env" }, { name = "pytest-flakefinder" }, { name = "pytest-report" }, { name = "pytest-timeout" }, @@ -360,6 +361,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, { name = "pytest-report", specifier = ">=0.2.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, @@ -586,9 +588,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, + { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" }, ] [[package]] @@ -941,6 +943,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "pytest-env" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" }, +] + [[package]] name = "pytest-flakefinder" version = "1.1.0"