Ensure that tools/templates/prompts are compatible with callable objects

This commit is contained in:
Jeremiah Lowin 2025-05-23 18:53:55 -04:00
commit 010170337f
13 changed files with 307 additions and 12 deletions

View file

@ -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"]

View file

@ -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 == "<lambda>":
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(),

View file

@ -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 == "<lambda>":
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,

View file

@ -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

View file

@ -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 == "<lambda>":
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()

View file

@ -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)

View file

@ -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."

View file

@ -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()

View file

@ -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."""

View file

@ -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"

View file

@ -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."""

View file

@ -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."""

19
uv.lock generated
View file

@ -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"