Jeremiah Lowin commited on
Commit
acef890
·
1 Parent(s): a1fa3c7

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

Browse files
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 or fn.__doc__,
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
- # Get schema from TypeAdapter - will fail if function isn't properly typed
152
- parameters = TypeAdapter(fn).json_schema()
 
 
 
 
 
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 or fn.__doc__ or "",
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(self.log_level)
 
 
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(console=Console(stderr=True), rich_tracebacks=True)
 
 
 
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"