Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
acef890
1
Parent(s): a1fa3c7
Ensure that tools/templates/prompts are compatible with callable objects
Browse files- pyproject.toml +6 -0
- src/fastmcp/prompts/prompt.py +8 -2
- src/fastmcp/resources/template.py +10 -5
- src/fastmcp/settings.py +13 -1
- src/fastmcp/tools/tool.py +5 -1
- src/fastmcp/utilities/logging.py +6 -1
- tests/prompts/test_prompt.py +24 -0
- tests/prompts/test_prompt_manager.py +44 -0
- tests/resources/test_resource_template.py +25 -0
- tests/server/test_server_interactions.py +46 -0
- tests/tools/test_tool.py +31 -0
- tests/tools/test_tool_manager.py +72 -0
- uv.lock +17 -2
pyproject.toml
CHANGED
|
@@ -48,6 +48,7 @@ dev = [
|
|
| 48 |
"pytest>=8.3.3",
|
| 49 |
"pytest-asyncio>=0.23.5",
|
| 50 |
"pytest-cov>=6.1.1",
|
|
|
|
| 51 |
"pytest-flakefinder",
|
| 52 |
"pytest-report>=0.2.1",
|
| 53 |
"pytest-timeout>=2.4.0",
|
|
@@ -84,6 +85,11 @@ asyncio_default_fixture_loop_scope = "session"
|
|
| 84 |
asyncio_default_test_loop_scope = "session"
|
| 85 |
filterwarnings = []
|
| 86 |
timeout = 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
[tool.pyright]
|
| 89 |
include = ["src", "tests"]
|
|
|
|
| 48 |
"pytest>=8.3.3",
|
| 49 |
"pytest-asyncio>=0.23.5",
|
| 50 |
"pytest-cov>=6.1.1",
|
| 51 |
+
"pytest-env>=1.1.5",
|
| 52 |
"pytest-flakefinder",
|
| 53 |
"pytest-report>=0.2.1",
|
| 54 |
"pytest-timeout>=2.4.0",
|
|
|
|
| 85 |
asyncio_default_test_loop_scope = "session"
|
| 86 |
filterwarnings = []
|
| 87 |
timeout = 3
|
| 88 |
+
env = [
|
| 89 |
+
"FASTMCP_TEST_MODE=1",
|
| 90 |
+
'D:FASTMCP_LOG_LEVEL=DEBUG',
|
| 91 |
+
'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0',
|
| 92 |
+
]
|
| 93 |
|
| 94 |
[tool.pyright]
|
| 95 |
include = ["src", "tests"]
|
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -97,7 +97,7 @@ class Prompt(BaseModel):
|
|
| 97 |
"""
|
| 98 |
from fastmcp.server.context import Context
|
| 99 |
|
| 100 |
-
func_name = name or fn.__name__
|
| 101 |
|
| 102 |
if func_name == "<lambda>":
|
| 103 |
raise ValueError("You must provide a name for lambda functions")
|
|
@@ -109,6 +109,12 @@ class Prompt(BaseModel):
|
|
| 109 |
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
| 110 |
raise ValueError("Functions with **kwargs are not supported as prompts")
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
type_adapter = get_cached_typeadapter(fn)
|
| 113 |
parameters = type_adapter.json_schema()
|
| 114 |
|
|
@@ -139,7 +145,7 @@ class Prompt(BaseModel):
|
|
| 139 |
|
| 140 |
return cls(
|
| 141 |
name=func_name,
|
| 142 |
-
description=description
|
| 143 |
arguments=arguments,
|
| 144 |
fn=fn,
|
| 145 |
tags=tags or set(),
|
|
|
|
| 97 |
"""
|
| 98 |
from fastmcp.server.context import Context
|
| 99 |
|
| 100 |
+
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
| 101 |
|
| 102 |
if func_name == "<lambda>":
|
| 103 |
raise ValueError("You must provide a name for lambda functions")
|
|
|
|
| 109 |
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
| 110 |
raise ValueError("Functions with **kwargs are not supported as prompts")
|
| 111 |
|
| 112 |
+
description = description or fn.__doc__
|
| 113 |
+
|
| 114 |
+
# if the fn is a callable class, we need to get the __call__ method from here out
|
| 115 |
+
if not inspect.isfunction(fn):
|
| 116 |
+
fn = fn.__call__
|
| 117 |
+
|
| 118 |
type_adapter = get_cached_typeadapter(fn)
|
| 119 |
parameters = type_adapter.json_schema()
|
| 120 |
|
|
|
|
| 145 |
|
| 146 |
return cls(
|
| 147 |
name=func_name,
|
| 148 |
+
description=description,
|
| 149 |
arguments=arguments,
|
| 150 |
fn=fn,
|
| 151 |
tags=tags or set(),
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -14,7 +14,6 @@ from pydantic import (
|
|
| 14 |
BaseModel,
|
| 15 |
BeforeValidator,
|
| 16 |
Field,
|
| 17 |
-
TypeAdapter,
|
| 18 |
field_validator,
|
| 19 |
validate_call,
|
| 20 |
)
|
|
@@ -25,6 +24,7 @@ from fastmcp.utilities.json_schema import compress_schema
|
|
| 25 |
from fastmcp.utilities.types import (
|
| 26 |
_convert_set_defaults,
|
| 27 |
find_kwarg_by_type,
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
|
|
@@ -97,7 +97,7 @@ class ResourceTemplate(BaseModel):
|
|
| 97 |
"""Create a template from a function."""
|
| 98 |
from fastmcp.server.context import Context
|
| 99 |
|
| 100 |
-
func_name = name or fn.__name__
|
| 101 |
if func_name == "<lambda>":
|
| 102 |
raise ValueError("You must provide a name for lambda functions")
|
| 103 |
|
|
@@ -148,8 +148,13 @@ class ResourceTemplate(BaseModel):
|
|
| 148 |
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
| 149 |
)
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
# compress the schema
|
| 155 |
prune_params = [context_kwarg] if context_kwarg else None
|
|
@@ -161,7 +166,7 @@ class ResourceTemplate(BaseModel):
|
|
| 161 |
return cls(
|
| 162 |
uri_template=uri_template,
|
| 163 |
name=func_name,
|
| 164 |
-
description=description
|
| 165 |
mime_type=mime_type or "text/plain",
|
| 166 |
fn=fn,
|
| 167 |
parameters=parameters,
|
|
|
|
| 14 |
BaseModel,
|
| 15 |
BeforeValidator,
|
| 16 |
Field,
|
|
|
|
| 17 |
field_validator,
|
| 18 |
validate_call,
|
| 19 |
)
|
|
|
|
| 24 |
from fastmcp.utilities.types import (
|
| 25 |
_convert_set_defaults,
|
| 26 |
find_kwarg_by_type,
|
| 27 |
+
get_cached_typeadapter,
|
| 28 |
)
|
| 29 |
|
| 30 |
|
|
|
|
| 97 |
"""Create a template from a function."""
|
| 98 |
from fastmcp.server.context import Context
|
| 99 |
|
| 100 |
+
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
| 101 |
if func_name == "<lambda>":
|
| 102 |
raise ValueError("You must provide a name for lambda functions")
|
| 103 |
|
|
|
|
| 148 |
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
| 149 |
)
|
| 150 |
|
| 151 |
+
description = description or fn.__doc__ or ""
|
| 152 |
+
|
| 153 |
+
if not inspect.isfunction(fn):
|
| 154 |
+
fn = fn.__call__
|
| 155 |
+
|
| 156 |
+
type_adapter = get_cached_typeadapter(fn)
|
| 157 |
+
parameters = type_adapter.json_schema()
|
| 158 |
|
| 159 |
# compress the schema
|
| 160 |
prune_params = [context_kwarg] if context_kwarg else None
|
|
|
|
| 166 |
return cls(
|
| 167 |
uri_template=uri_template,
|
| 168 |
name=func_name,
|
| 169 |
+
description=description,
|
| 170 |
mime_type=mime_type or "text/plain",
|
| 171 |
fn=fn,
|
| 172 |
parameters=parameters,
|
src/fastmcp/settings.py
CHANGED
|
@@ -29,6 +29,16 @@ class Settings(BaseSettings):
|
|
| 29 |
|
| 30 |
test_mode: bool = False
|
| 31 |
log_level: LOG_LEVEL = "INFO"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
client_raise_first_exceptiongroup_error: Annotated[
|
| 34 |
bool,
|
|
@@ -82,7 +92,9 @@ class Settings(BaseSettings):
|
|
| 82 |
"""Finalize the settings."""
|
| 83 |
from fastmcp.utilities.logging import configure_logging
|
| 84 |
|
| 85 |
-
configure_logging(
|
|
|
|
|
|
|
| 86 |
|
| 87 |
return self
|
| 88 |
|
|
|
|
| 29 |
|
| 30 |
test_mode: bool = False
|
| 31 |
log_level: LOG_LEVEL = "INFO"
|
| 32 |
+
enable_rich_tracebacks: Annotated[
|
| 33 |
+
bool,
|
| 34 |
+
Field(
|
| 35 |
+
description=inspect.cleandoc(
|
| 36 |
+
"""
|
| 37 |
+
If True, will use rich tracebacks for logging.
|
| 38 |
+
"""
|
| 39 |
+
)
|
| 40 |
+
),
|
| 41 |
+
] = True
|
| 42 |
|
| 43 |
client_raise_first_exceptiongroup_error: Annotated[
|
| 44 |
bool,
|
|
|
|
| 92 |
"""Finalize the settings."""
|
| 93 |
from fastmcp.utilities.logging import configure_logging
|
| 94 |
|
| 95 |
+
configure_logging(
|
| 96 |
+
self.log_level, enable_rich_tracebacks=self.enable_rich_tracebacks
|
| 97 |
+
)
|
| 98 |
|
| 99 |
return self
|
| 100 |
|
src/fastmcp/tools/tool.py
CHANGED
|
@@ -69,13 +69,17 @@ class Tool(BaseModel):
|
|
| 69 |
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
| 70 |
raise ValueError("Functions with **kwargs are not supported as tools")
|
| 71 |
|
| 72 |
-
func_name = name or fn.__name__
|
| 73 |
|
| 74 |
if func_name == "<lambda>":
|
| 75 |
raise ValueError("You must provide a name for lambda functions")
|
| 76 |
|
| 77 |
func_doc = description or fn.__doc__ or ""
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
type_adapter = get_cached_typeadapter(fn)
|
| 80 |
schema = type_adapter.json_schema()
|
| 81 |
|
|
|
|
| 69 |
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
| 70 |
raise ValueError("Functions with **kwargs are not supported as tools")
|
| 71 |
|
| 72 |
+
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
|
| 73 |
|
| 74 |
if func_name == "<lambda>":
|
| 75 |
raise ValueError("You must provide a name for lambda functions")
|
| 76 |
|
| 77 |
func_doc = description or fn.__doc__ or ""
|
| 78 |
|
| 79 |
+
# if the fn is a callable class, we need to get the __call__ method from here out
|
| 80 |
+
if not inspect.isfunction(fn):
|
| 81 |
+
fn = fn.__call__
|
| 82 |
+
|
| 83 |
type_adapter = get_cached_typeadapter(fn)
|
| 84 |
schema = type_adapter.json_schema()
|
| 85 |
|
src/fastmcp/utilities/logging.py
CHANGED
|
@@ -22,6 +22,7 @@ def get_logger(name: str) -> logging.Logger:
|
|
| 22 |
def configure_logging(
|
| 23 |
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
|
| 24 |
logger: logging.Logger | None = None,
|
|
|
|
| 25 |
) -> None:
|
| 26 |
"""
|
| 27 |
Configure logging for FastMCP.
|
|
@@ -30,11 +31,15 @@ def configure_logging(
|
|
| 30 |
logger: the logger to configure
|
| 31 |
level: the log level to use
|
| 32 |
"""
|
|
|
|
| 33 |
if logger is None:
|
| 34 |
logger = logging.getLogger("FastMCP")
|
| 35 |
|
| 36 |
# Only configure the FastMCP logger namespace
|
| 37 |
-
handler = RichHandler(
|
|
|
|
|
|
|
|
|
|
| 38 |
formatter = logging.Formatter("%(message)s")
|
| 39 |
handler.setFormatter(formatter)
|
| 40 |
|
|
|
|
| 22 |
def configure_logging(
|
| 23 |
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
|
| 24 |
logger: logging.Logger | None = None,
|
| 25 |
+
enable_rich_tracebacks: bool = True,
|
| 26 |
) -> None:
|
| 27 |
"""
|
| 28 |
Configure logging for FastMCP.
|
|
|
|
| 31 |
logger: the logger to configure
|
| 32 |
level: the log level to use
|
| 33 |
"""
|
| 34 |
+
|
| 35 |
if logger is None:
|
| 36 |
logger = logging.getLogger("FastMCP")
|
| 37 |
|
| 38 |
# Only configure the FastMCP logger namespace
|
| 39 |
+
handler = RichHandler(
|
| 40 |
+
console=Console(stderr=True),
|
| 41 |
+
rich_tracebacks=enable_rich_tracebacks,
|
| 42 |
+
)
|
| 43 |
formatter = logging.Formatter("%(message)s")
|
| 44 |
handler.setFormatter(formatter)
|
| 45 |
|
tests/prompts/test_prompt.py
CHANGED
|
@@ -47,6 +47,30 @@ class TestRenderPrompt:
|
|
| 47 |
)
|
| 48 |
]
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
async def test_fn_with_invalid_kwargs(self):
|
| 51 |
async def fn(name: str, age: int = 30) -> str:
|
| 52 |
return f"Hello, {name}! You're {age} years old."
|
|
|
|
| 47 |
)
|
| 48 |
]
|
| 49 |
|
| 50 |
+
async def test_callable_object(self):
|
| 51 |
+
class MyPrompt:
|
| 52 |
+
def __call__(self, name: str) -> str:
|
| 53 |
+
return f"Hello, {name}!"
|
| 54 |
+
|
| 55 |
+
prompt = Prompt.from_function(MyPrompt())
|
| 56 |
+
assert await prompt.render(arguments=dict(name="World")) == [
|
| 57 |
+
PromptMessage(
|
| 58 |
+
role="user", content=TextContent(type="text", text="Hello, World!")
|
| 59 |
+
)
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
async def test_async_callable_object(self):
|
| 63 |
+
class MyPrompt:
|
| 64 |
+
async def __call__(self, name: str) -> str:
|
| 65 |
+
return f"Hello, {name}!"
|
| 66 |
+
|
| 67 |
+
prompt = Prompt.from_function(MyPrompt())
|
| 68 |
+
assert await prompt.render(arguments=dict(name="World")) == [
|
| 69 |
+
PromptMessage(
|
| 70 |
+
role="user", content=TextContent(type="text", text="Hello, World!")
|
| 71 |
+
)
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
async def test_fn_with_invalid_kwargs(self):
|
| 75 |
async def fn(name: str, age: int = 30) -> str:
|
| 76 |
return f"Hello, {name}! You're {age} years old."
|
tests/prompts/test_prompt_manager.py
CHANGED
|
@@ -141,6 +141,8 @@ class TestPromptManager:
|
|
| 141 |
assert prompts["fn1"] == prompt1
|
| 142 |
assert prompts["fn2"] == prompt2
|
| 143 |
|
|
|
|
|
|
|
| 144 |
async def test_render_prompt(self):
|
| 145 |
"""Test rendering a prompt."""
|
| 146 |
|
|
@@ -177,6 +179,48 @@ class TestPromptManager:
|
|
| 177 |
)
|
| 178 |
]
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
async def test_render_unknown_prompt(self):
|
| 181 |
"""Test rendering a non-existent prompt."""
|
| 182 |
manager = PromptManager()
|
|
|
|
| 141 |
assert prompts["fn1"] == prompt1
|
| 142 |
assert prompts["fn2"] == prompt2
|
| 143 |
|
| 144 |
+
|
| 145 |
+
class TestRenderPrompt:
|
| 146 |
async def test_render_prompt(self):
|
| 147 |
"""Test rendering a prompt."""
|
| 148 |
|
|
|
|
| 179 |
)
|
| 180 |
]
|
| 181 |
|
| 182 |
+
async def test_render_prompt_callable_object(self):
|
| 183 |
+
"""Test rendering a prompt with a callable object."""
|
| 184 |
+
|
| 185 |
+
class MyPrompt:
|
| 186 |
+
"""A callable object that can be used as a prompt."""
|
| 187 |
+
|
| 188 |
+
def __call__(self, name: str) -> str:
|
| 189 |
+
"""ignore this"""
|
| 190 |
+
return f"Hello, {name}!"
|
| 191 |
+
|
| 192 |
+
manager = PromptManager()
|
| 193 |
+
prompt = Prompt.from_function(MyPrompt())
|
| 194 |
+
manager.add_prompt(prompt)
|
| 195 |
+
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
|
| 196 |
+
assert result.description == "A callable object that can be used as a prompt."
|
| 197 |
+
assert result.messages == [
|
| 198 |
+
PromptMessage(
|
| 199 |
+
role="user", content=TextContent(type="text", text="Hello, World!")
|
| 200 |
+
)
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
async def test_render_prompt_callable_object_async(self):
|
| 204 |
+
"""Test rendering a prompt with a callable object."""
|
| 205 |
+
|
| 206 |
+
class MyPrompt:
|
| 207 |
+
"""A callable object that can be used as a prompt."""
|
| 208 |
+
|
| 209 |
+
async def __call__(self, name: str) -> str:
|
| 210 |
+
"""ignore this"""
|
| 211 |
+
return f"Hello, {name}!"
|
| 212 |
+
|
| 213 |
+
manager = PromptManager()
|
| 214 |
+
prompt = Prompt.from_function(MyPrompt())
|
| 215 |
+
manager.add_prompt(prompt)
|
| 216 |
+
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
|
| 217 |
+
assert result.description == "A callable object that can be used as a prompt."
|
| 218 |
+
assert result.messages == [
|
| 219 |
+
PromptMessage(
|
| 220 |
+
role="user", content=TextContent(type="text", text="Hello, World!")
|
| 221 |
+
)
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
async def test_render_unknown_prompt(self):
|
| 225 |
"""Test rendering a non-existent prompt."""
|
| 226 |
manager = PromptManager()
|
tests/resources/test_resource_template.py
CHANGED
|
@@ -368,6 +368,31 @@ class TestResourceTemplate:
|
|
| 368 |
)
|
| 369 |
assert template.uri_template == "test://{x}/{y}/{z}"
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
class TestMatchUriTemplate:
|
| 373 |
"""Test match_uri_template function."""
|
|
|
|
| 368 |
)
|
| 369 |
assert template.uri_template == "test://{x}/{y}/{z}"
|
| 370 |
|
| 371 |
+
async def test_callable_object_as_template(self):
|
| 372 |
+
"""Test that a callable object can be used as a template."""
|
| 373 |
+
|
| 374 |
+
class MyTemplate:
|
| 375 |
+
"""This is my template"""
|
| 376 |
+
|
| 377 |
+
def __call__(self, x: str) -> str:
|
| 378 |
+
"""ignore this"""
|
| 379 |
+
return f"X was {x}"
|
| 380 |
+
|
| 381 |
+
template = ResourceTemplate.from_function(
|
| 382 |
+
fn=MyTemplate(),
|
| 383 |
+
uri_template="test://{x}",
|
| 384 |
+
name="test",
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
resource = await template.create_resource(
|
| 388 |
+
"test://foo",
|
| 389 |
+
{"x": "foo"},
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
assert isinstance(resource, FunctionResource)
|
| 393 |
+
content = await resource.read()
|
| 394 |
+
assert content == "X was foo"
|
| 395 |
+
|
| 396 |
|
| 397 |
class TestMatchUriTemplate:
|
| 398 |
"""Test match_uri_template function."""
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -709,6 +709,21 @@ class TestToolContextInjection:
|
|
| 709 |
assert len(tools) == 1
|
| 710 |
# Note: MCPTool from the client API doesn't expose tags
|
| 711 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
|
| 713 |
class TestResource:
|
| 714 |
async def test_text_resource(self):
|
|
@@ -1096,6 +1111,20 @@ class TestResourceTemplateContext:
|
|
| 1096 |
assert isinstance(result[0], TextResourceContents)
|
| 1097 |
assert result[0].text.startswith("Resource template: test 2")
|
| 1098 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
| 1100 |
class TestPrompts:
|
| 1101 |
"""Test prompt functionality in FastMCP server."""
|
|
@@ -1298,3 +1327,20 @@ class TestPromptContext:
|
|
| 1298 |
assert len(result.messages) == 1
|
| 1299 |
message = result.messages[0]
|
| 1300 |
assert message.role == "user"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
assert len(tools) == 1
|
| 710 |
# Note: MCPTool from the client API doesn't expose tags
|
| 711 |
|
| 712 |
+
async def test_callable_object_with_context(self):
|
| 713 |
+
"""Test that a callable object can be used as a tool with context."""
|
| 714 |
+
mcp = FastMCP()
|
| 715 |
+
|
| 716 |
+
class MyTool:
|
| 717 |
+
async def __call__(self, x: int, ctx: Context) -> int:
|
| 718 |
+
return x + int(ctx.request_id)
|
| 719 |
+
|
| 720 |
+
mcp.add_tool(MyTool())
|
| 721 |
+
|
| 722 |
+
async with Client(mcp) as client:
|
| 723 |
+
result = await client.call_tool("MyTool", {"x": 2})
|
| 724 |
+
assert isinstance(result[0], TextContent)
|
| 725 |
+
assert result[0].text == "4"
|
| 726 |
+
|
| 727 |
|
| 728 |
class TestResource:
|
| 729 |
async def test_text_resource(self):
|
|
|
|
| 1111 |
assert isinstance(result[0], TextResourceContents)
|
| 1112 |
assert result[0].text.startswith("Resource template: test 2")
|
| 1113 |
|
| 1114 |
+
async def test_resource_template_context_with_callable_object(self):
|
| 1115 |
+
mcp = FastMCP()
|
| 1116 |
+
|
| 1117 |
+
class MyResource:
|
| 1118 |
+
def __call__(self, param: str, ctx: Context) -> str:
|
| 1119 |
+
return f"Resource template: {param} {ctx.request_id}"
|
| 1120 |
+
|
| 1121 |
+
mcp.add_resource_fn(MyResource(), uri="resource://{param}")
|
| 1122 |
+
|
| 1123 |
+
async with Client(mcp) as client:
|
| 1124 |
+
result = await client.read_resource(AnyUrl("resource://test"))
|
| 1125 |
+
assert isinstance(result[0], TextResourceContents)
|
| 1126 |
+
assert result[0].text.startswith("Resource template: test 2")
|
| 1127 |
+
|
| 1128 |
|
| 1129 |
class TestPrompts:
|
| 1130 |
"""Test prompt functionality in FastMCP server."""
|
|
|
|
| 1327 |
assert len(result.messages) == 1
|
| 1328 |
message = result.messages[0]
|
| 1329 |
assert message.role == "user"
|
| 1330 |
+
|
| 1331 |
+
async def test_prompt_context_with_callable_object(self):
|
| 1332 |
+
mcp = FastMCP()
|
| 1333 |
+
|
| 1334 |
+
class MyPrompt:
|
| 1335 |
+
def __call__(self, name: str, ctx: Context) -> str:
|
| 1336 |
+
return f"Hello, {name}! {ctx.request_id}"
|
| 1337 |
+
|
| 1338 |
+
mcp.add_prompt(MyPrompt(), name="my_prompt")
|
| 1339 |
+
|
| 1340 |
+
async with Client(mcp) as client:
|
| 1341 |
+
result = await client.get_prompt("my_prompt", {"name": "World"})
|
| 1342 |
+
assert len(result.messages) == 1
|
| 1343 |
+
message = result.messages[0]
|
| 1344 |
+
assert message.role == "user"
|
| 1345 |
+
assert isinstance(message.content, TextContent)
|
| 1346 |
+
assert message.content.text == "Hello, World! 2"
|
tests/tools/test_tool.py
CHANGED
|
@@ -21,6 +21,7 @@ class TestToolFromFunction:
|
|
| 21 |
|
| 22 |
assert tool.name == "add"
|
| 23 |
assert tool.description == "Add two numbers."
|
|
|
|
| 24 |
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
| 25 |
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
| 26 |
|
|
@@ -37,6 +38,36 @@ class TestToolFromFunction:
|
|
| 37 |
assert tool.description == "Fetch data from URL."
|
| 38 |
assert tool.parameters["properties"]["url"]["type"] == "string"
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def test_pydantic_model_function(self):
|
| 41 |
"""Test registering a function that takes a Pydantic model."""
|
| 42 |
|
|
|
|
| 21 |
|
| 22 |
assert tool.name == "add"
|
| 23 |
assert tool.description == "Add two numbers."
|
| 24 |
+
assert len(tool.parameters["properties"]) == 2
|
| 25 |
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
| 26 |
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
| 27 |
|
|
|
|
| 38 |
assert tool.description == "Fetch data from URL."
|
| 39 |
assert tool.parameters["properties"]["url"]["type"] == "string"
|
| 40 |
|
| 41 |
+
def test_callable_object(self):
|
| 42 |
+
class Adder:
|
| 43 |
+
"""Adds two numbers."""
|
| 44 |
+
|
| 45 |
+
def __call__(self, x: int, y: int) -> int:
|
| 46 |
+
"""ignore this"""
|
| 47 |
+
return x + y
|
| 48 |
+
|
| 49 |
+
tool = Tool.from_function(Adder())
|
| 50 |
+
assert tool.name == "Adder"
|
| 51 |
+
assert tool.description == "Adds two numbers."
|
| 52 |
+
assert len(tool.parameters["properties"]) == 2
|
| 53 |
+
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 54 |
+
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 55 |
+
|
| 56 |
+
def test_async_callable_object(self):
|
| 57 |
+
class Adder:
|
| 58 |
+
"""Adds two numbers."""
|
| 59 |
+
|
| 60 |
+
async def __call__(self, x: int, y: int) -> int:
|
| 61 |
+
"""ignore this"""
|
| 62 |
+
return x + y
|
| 63 |
+
|
| 64 |
+
tool = Tool.from_function(Adder())
|
| 65 |
+
assert tool.name == "Adder"
|
| 66 |
+
assert tool.description == "Adds two numbers."
|
| 67 |
+
assert len(tool.parameters["properties"]) == 2
|
| 68 |
+
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 69 |
+
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 70 |
+
|
| 71 |
def test_pydantic_model_function(self):
|
| 72 |
"""Test registering a function that takes a Pydantic model."""
|
| 73 |
|
tests/tools/test_tool_manager.py
CHANGED
|
@@ -71,6 +71,44 @@ class TestAddTools:
|
|
| 71 |
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 72 |
assert "flag" in tool.parameters["properties"]
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
async def test_tool_with_image_return(self):
|
| 75 |
def image_tool(data: bytes) -> Image:
|
| 76 |
return Image(data=data)
|
|
@@ -303,6 +341,40 @@ class TestCallTools:
|
|
| 303 |
assert result[0].text == "10"
|
| 304 |
assert json.loads(result[0].text) == 10
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
async def test_call_tool_with_default_args(self):
|
| 307 |
def add(a: int, b: int = 1) -> int:
|
| 308 |
"""Add two numbers."""
|
|
|
|
| 71 |
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
| 72 |
assert "flag" in tool.parameters["properties"]
|
| 73 |
|
| 74 |
+
def test_callable_object(self):
|
| 75 |
+
class Adder:
|
| 76 |
+
"""Adds two numbers."""
|
| 77 |
+
|
| 78 |
+
def __call__(self, x: int, y: int) -> int:
|
| 79 |
+
"""ignore this"""
|
| 80 |
+
return x + y
|
| 81 |
+
|
| 82 |
+
manager = ToolManager()
|
| 83 |
+
manager.add_tool_from_fn(Adder())
|
| 84 |
+
|
| 85 |
+
tool = manager.get_tool("Adder")
|
| 86 |
+
assert tool is not None
|
| 87 |
+
assert tool.name == "Adder"
|
| 88 |
+
assert tool.description == "Adds two numbers."
|
| 89 |
+
assert len(tool.parameters["properties"]) == 2
|
| 90 |
+
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 91 |
+
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 92 |
+
|
| 93 |
+
def test_async_callable_object(self):
|
| 94 |
+
class Adder:
|
| 95 |
+
"""Adds two numbers."""
|
| 96 |
+
|
| 97 |
+
async def __call__(self, x: int, y: int) -> int:
|
| 98 |
+
"""ignore this"""
|
| 99 |
+
return x + y
|
| 100 |
+
|
| 101 |
+
manager = ToolManager()
|
| 102 |
+
manager.add_tool_from_fn(Adder())
|
| 103 |
+
|
| 104 |
+
tool = manager.get_tool("Adder")
|
| 105 |
+
assert tool is not None
|
| 106 |
+
assert tool.name == "Adder"
|
| 107 |
+
assert tool.description == "Adds two numbers."
|
| 108 |
+
assert len(tool.parameters["properties"]) == 2
|
| 109 |
+
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
| 110 |
+
assert tool.parameters["properties"]["y"]["type"] == "integer"
|
| 111 |
+
|
| 112 |
async def test_tool_with_image_return(self):
|
| 113 |
def image_tool(data: bytes) -> Image:
|
| 114 |
return Image(data=data)
|
|
|
|
| 341 |
assert result[0].text == "10"
|
| 342 |
assert json.loads(result[0].text) == 10
|
| 343 |
|
| 344 |
+
async def test_call_tool_callable_object(self):
|
| 345 |
+
class Adder:
|
| 346 |
+
"""Adds two numbers."""
|
| 347 |
+
|
| 348 |
+
def __call__(self, x: int, y: int) -> int:
|
| 349 |
+
"""ignore this"""
|
| 350 |
+
return x + y
|
| 351 |
+
|
| 352 |
+
manager = ToolManager()
|
| 353 |
+
manager.add_tool_from_fn(Adder())
|
| 354 |
+
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 355 |
+
assert isinstance(result, list)
|
| 356 |
+
assert len(result) == 1
|
| 357 |
+
assert isinstance(result[0], TextContent)
|
| 358 |
+
assert result[0].text == "3"
|
| 359 |
+
assert json.loads(result[0].text) == 3
|
| 360 |
+
|
| 361 |
+
async def test_call_tool_callable_object_async(self):
|
| 362 |
+
class Adder:
|
| 363 |
+
"""Adds two numbers."""
|
| 364 |
+
|
| 365 |
+
async def __call__(self, x: int, y: int) -> int:
|
| 366 |
+
"""ignore this"""
|
| 367 |
+
return x + y
|
| 368 |
+
|
| 369 |
+
manager = ToolManager()
|
| 370 |
+
manager.add_tool_from_fn(Adder())
|
| 371 |
+
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
|
| 372 |
+
assert isinstance(result, list)
|
| 373 |
+
assert len(result) == 1
|
| 374 |
+
assert isinstance(result[0], TextContent)
|
| 375 |
+
assert result[0].text == "3"
|
| 376 |
+
assert json.loads(result[0].text) == 3
|
| 377 |
+
|
| 378 |
async def test_call_tool_with_default_args(self):
|
| 379 |
def add(a: int, b: int = 1) -> int:
|
| 380 |
"""Add two numbers."""
|
uv.lock
CHANGED
|
@@ -329,6 +329,7 @@ dev = [
|
|
| 329 |
{ name = "pytest" },
|
| 330 |
{ name = "pytest-asyncio" },
|
| 331 |
{ name = "pytest-cov" },
|
|
|
|
| 332 |
{ name = "pytest-flakefinder" },
|
| 333 |
{ name = "pytest-report" },
|
| 334 |
{ name = "pytest-timeout" },
|
|
@@ -360,6 +361,7 @@ dev = [
|
|
| 360 |
{ name = "pytest", specifier = ">=8.3.3" },
|
| 361 |
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
| 362 |
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
|
|
|
| 363 |
{ name = "pytest-flakefinder" },
|
| 364 |
{ name = "pytest-report", specifier = ">=0.2.1" },
|
| 365 |
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
|
@@ -586,9 +588,9 @@ dependencies = [
|
|
| 586 |
{ name = "starlette" },
|
| 587 |
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
| 588 |
]
|
| 589 |
-
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
|
| 590 |
wheels = [
|
| 591 |
-
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
|
| 592 |
]
|
| 593 |
|
| 594 |
[[package]]
|
|
@@ -941,6 +943,19 @@ wheels = [
|
|
| 941 |
{ 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" },
|
| 942 |
]
|
| 943 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
[[package]]
|
| 945 |
name = "pytest-flakefinder"
|
| 946 |
version = "1.1.0"
|
|
|
|
| 329 |
{ name = "pytest" },
|
| 330 |
{ name = "pytest-asyncio" },
|
| 331 |
{ name = "pytest-cov" },
|
| 332 |
+
{ name = "pytest-env" },
|
| 333 |
{ name = "pytest-flakefinder" },
|
| 334 |
{ name = "pytest-report" },
|
| 335 |
{ name = "pytest-timeout" },
|
|
|
|
| 361 |
{ name = "pytest", specifier = ">=8.3.3" },
|
| 362 |
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
|
| 363 |
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
| 364 |
+
{ name = "pytest-env", specifier = ">=1.1.5" },
|
| 365 |
{ name = "pytest-flakefinder" },
|
| 366 |
{ name = "pytest-report", specifier = ">=0.2.1" },
|
| 367 |
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
|
|
|
| 588 |
{ name = "starlette" },
|
| 589 |
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
| 590 |
]
|
| 591 |
+
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" }
|
| 592 |
wheels = [
|
| 593 |
+
{ 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" },
|
| 594 |
]
|
| 595 |
|
| 596 |
[[package]]
|
|
|
|
| 943 |
{ 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" },
|
| 944 |
]
|
| 945 |
|
| 946 |
+
[[package]]
|
| 947 |
+
name = "pytest-env"
|
| 948 |
+
version = "1.1.5"
|
| 949 |
+
source = { registry = "https://pypi.org/simple" }
|
| 950 |
+
dependencies = [
|
| 951 |
+
{ name = "pytest" },
|
| 952 |
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
| 953 |
+
]
|
| 954 |
+
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" }
|
| 955 |
+
wheels = [
|
| 956 |
+
{ 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" },
|
| 957 |
+
]
|
| 958 |
+
|
| 959 |
[[package]]
|
| 960 |
name = "pytest-flakefinder"
|
| 961 |
version = "1.1.0"
|