Jeremiah Lowin commited on
Commit
ebdeab3
·
1 Parent(s): da5987c

Update output schema control

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
- from typing import TYPE_CHECKING, Annotated, Any
7
 
8
  import mcp.types
9
  import pydantic_core
@@ -40,6 +40,27 @@ def default_serializer(data: Any) -> str:
40
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  class ToolResult:
44
  def __init__(
45
  self,
@@ -64,7 +85,11 @@ class ToolResult:
64
  )
65
  raise
66
  if not isinstance(structured_content, dict):
67
- structured_content = {"result": structured_content}
 
 
 
 
68
  self.structured_content: dict[str, Any] | None = structured_content
69
 
70
  def to_mcp_result(
@@ -127,7 +152,7 @@ class Tool(FastMCPComponent):
127
  tags: set[str] | None = None,
128
  annotations: ToolAnnotations | None = None,
129
  exclude_args: list[str] | None = None,
130
- output_schema: dict[str, Any] | None | NotSetT = NotSet,
131
  serializer: Callable[[Any], str] | None = None,
132
  enabled: bool | None = None,
133
  ) -> FunctionTool:
@@ -166,6 +191,7 @@ class Tool(FastMCPComponent):
166
  description: str | None = None,
167
  tags: set[str] | None = None,
168
  annotations: ToolAnnotations | None = None,
 
169
  serializer: Callable[[Any], str] | None = None,
170
  enabled: bool | None = None,
171
  ) -> TransformedTool:
@@ -179,6 +205,7 @@ class Tool(FastMCPComponent):
179
  description=description,
180
  tags=tags,
181
  annotations=annotations,
 
182
  serializer=serializer,
183
  enabled=enabled,
184
  )
@@ -196,7 +223,7 @@ class FunctionTool(Tool):
196
  tags: set[str] | None = None,
197
  annotations: ToolAnnotations | None = None,
198
  exclude_args: list[str] | None = None,
199
- output_schema: dict[str, Any] | None | NotSetT = NotSet,
200
  serializer: Callable[[Any], str] | None = None,
201
  enabled: bool | None = None,
202
  ) -> FunctionTool:
@@ -208,14 +235,10 @@ class FunctionTool(Tool):
208
  raise ValueError("You must provide a name for lambda functions")
209
 
210
  if isinstance(output_schema, NotSetT):
211
- output_schema = parsed_fn.output_schema
212
-
213
- if output_schema and output_schema.get("type") != "object":
214
- output_schema = {
215
- "type": "object",
216
- "properties": {"result": output_schema},
217
- "x-fastmcp-wrap-result": True,
218
- }
219
 
220
  return cls(
221
  fn=parsed_fn.fn,
@@ -249,6 +272,7 @@ class FunctionTool(Tool):
249
 
250
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
251
 
 
252
  if self.output_schema is not None:
253
  if self.output_schema.get("x-fastmcp-wrap-result"):
254
  structured_output = {"result": result}
 
3
  import inspect
4
  from collections.abc import Callable
5
  from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
7
 
8
  import mcp.types
9
  import pydantic_core
 
40
  return pydantic_core.to_json(data, fallback=str, indent=2).decode()
41
 
42
 
43
+ def _wrap_schema_if_needed(schema: dict[str, Any] | None) -> dict[str, Any] | None:
44
+ """Wrap non-object schemas with result property for structured output.
45
+
46
+ This wrapping allows primitive types (int, str, etc.) to be returned as
47
+ structured content by placing them under a "result" key.
48
+
49
+ Args:
50
+ schema: The JSON schema to potentially wrap
51
+
52
+ Returns:
53
+ Wrapped schema if needed, or original schema if already an object type
54
+ """
55
+ if schema and schema.get("type") != "object":
56
+ return {
57
+ "type": "object",
58
+ "properties": {"result": schema},
59
+ "x-fastmcp-wrap-result": True,
60
+ }
61
+ return schema
62
+
63
+
64
  class ToolResult:
65
  def __init__(
66
  self,
 
85
  )
86
  raise
87
  if not isinstance(structured_content, dict):
88
+ raise ValueError(
89
+ "structured_content must be a dict or None. "
90
+ f"Got {type(structured_content).__name__}: {structured_content!r}. "
91
+ "Tools should wrap non-dict values based on their output_schema."
92
+ )
93
  self.structured_content: dict[str, Any] | None = structured_content
94
 
95
  def to_mcp_result(
 
152
  tags: set[str] | None = None,
153
  annotations: ToolAnnotations | None = None,
154
  exclude_args: list[str] | None = None,
155
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
156
  serializer: Callable[[Any], str] | None = None,
157
  enabled: bool | None = None,
158
  ) -> FunctionTool:
 
191
  description: str | None = None,
192
  tags: set[str] | None = None,
193
  annotations: ToolAnnotations | None = None,
194
+ output_schema: dict[str, Any] | None | Literal[False] = None,
195
  serializer: Callable[[Any], str] | None = None,
196
  enabled: bool | None = None,
197
  ) -> TransformedTool:
 
205
  description=description,
206
  tags=tags,
207
  annotations=annotations,
208
+ output_schema=output_schema,
209
  serializer=serializer,
210
  enabled=enabled,
211
  )
 
223
  tags: set[str] | None = None,
224
  annotations: ToolAnnotations | None = None,
225
  exclude_args: list[str] | None = None,
226
+ output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
227
  serializer: Callable[[Any], str] | None = None,
228
  enabled: bool | None = None,
229
  ) -> FunctionTool:
 
235
  raise ValueError("You must provide a name for lambda functions")
236
 
237
  if isinstance(output_schema, NotSetT):
238
+ output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
239
+ elif output_schema is False:
240
+ output_schema = None
241
+ # Note: explicit schemas (dict) are used as-is without auto-wrapping
 
 
 
 
242
 
243
  return cls(
244
  fn=parsed_fn.fn,
 
272
 
273
  unstructured_result = _convert_to_content(result, serializer=self.serializer)
274
 
275
+ # Handle structured content based on output schema
276
  if self.output_schema is not None:
277
  if self.output_schema.get("x-fastmcp-wrap-result"):
278
  structured_output = {"result": result}
src/fastmcp/tools/tool_transform.py CHANGED
@@ -9,7 +9,7 @@ from typing import Any, Literal
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
- from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
@@ -52,7 +52,7 @@ async def forward(**kwargs) -> ToolResult:
52
  return await tool.forwarding_fn(**kwargs)
53
 
54
 
55
- async def forward_raw(**kwargs) -> Any:
56
  """Forward directly to parent tool without transformation.
57
 
58
  This function bypasses all argument transformation and validation, calling the parent
@@ -66,7 +66,7 @@ async def forward_raw(**kwargs) -> Any:
66
  **kwargs: Arguments to pass directly to the parent tool (using original names).
67
 
68
  Returns:
69
- The result from the parent tool execution.
70
 
71
  Raises:
72
  RuntimeError: If called outside a transformed tool context.
@@ -268,15 +268,31 @@ class TransformedTool(Tool):
268
  token = _current_tool.set(self)
269
  try:
270
  result = await self.fn(**arguments)
271
-
272
  # If transform function returns ToolResult, use it directly
273
  if isinstance(result, ToolResult):
274
  return result
275
-
276
- # Otherwise convert to content and create basic ToolResult
277
  from fastmcp.tools.tool import _convert_to_content
278
- unstructured_result = _convert_to_content(result, serializer=self.serializer)
279
- return ToolResult(content=unstructured_result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  finally:
281
  _current_tool.reset(token)
282
 
@@ -290,6 +306,7 @@ class TransformedTool(Tool):
290
  transform_fn: Callable[..., Any] | None = None,
291
  transform_args: dict[str, ArgTransform] | None = None,
292
  annotations: ToolAnnotations | None = None,
 
293
  serializer: Callable[[Any], str] | None = None,
294
  enabled: bool | None = None,
295
  ) -> TransformedTool:
@@ -352,13 +369,30 @@ class TransformedTool(Tool):
352
  # Always create the forwarding transform
353
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  if transform_fn is None:
356
  # User wants pure transformation - use forwarding_fn as the main function
357
  final_fn = forwarding_fn
358
  final_schema = schema
359
  else:
360
  # User provided custom function - merge schemas
361
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
 
362
  final_fn = transform_fn
363
 
364
  has_kwargs = cls._function_has_kwargs(transform_fn)
@@ -426,6 +460,7 @@ class TransformedTool(Tool):
426
  name=name or tool.name,
427
  description=final_description,
428
  parameters=final_schema,
 
429
  tags=tags or tool.tags,
430
  annotations=annotations or tool.annotations,
431
  serializer=serializer or tool.serializer,
 
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
 
12
+ from fastmcp.tools.tool import ParsedFunction, Tool, ToolResult, _wrap_schema_if_needed
13
  from fastmcp.utilities.logging import get_logger
14
  from fastmcp.utilities.types import NotSet, NotSetT, get_cached_typeadapter
15
 
 
52
  return await tool.forwarding_fn(**kwargs)
53
 
54
 
55
+ async def forward_raw(**kwargs) -> ToolResult:
56
  """Forward directly to parent tool without transformation.
57
 
58
  This function bypasses all argument transformation and validation, calling the parent
 
66
  **kwargs: Arguments to pass directly to the parent tool (using original names).
67
 
68
  Returns:
69
+ The ToolResult from the parent tool execution.
70
 
71
  Raises:
72
  RuntimeError: If called outside a transformed tool context.
 
268
  token = _current_tool.set(self)
269
  try:
270
  result = await self.fn(**arguments)
271
+
272
  # If transform function returns ToolResult, use it directly
273
  if isinstance(result, ToolResult):
274
  return result
275
+
276
+ # Otherwise convert to content and create ToolResult with proper structured content
277
  from fastmcp.tools.tool import _convert_to_content
278
+
279
+ unstructured_result = _convert_to_content(
280
+ result, serializer=self.serializer
281
+ )
282
+
283
+ # Handle structured content based on output schema
284
+ if self.output_schema is not None:
285
+ if self.output_schema.get("x-fastmcp-wrap-result"):
286
+ structured_output = {"result": result}
287
+ else:
288
+ structured_output = result
289
+ else:
290
+ structured_output = None
291
+
292
+ return ToolResult(
293
+ content=unstructured_result,
294
+ structured_content=structured_output,
295
+ )
296
  finally:
297
  _current_tool.reset(token)
298
 
 
306
  transform_fn: Callable[..., Any] | None = None,
307
  transform_args: dict[str, ArgTransform] | None = None,
308
  annotations: ToolAnnotations | None = None,
309
+ output_schema: dict[str, Any] | None | Literal[False] = None,
310
  serializer: Callable[[Any], str] | None = None,
311
  enabled: bool | None = None,
312
  ) -> TransformedTool:
 
369
  # Always create the forwarding transform
370
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
371
 
372
+ # Handle output schema with smart fallback
373
+ if output_schema is False:
374
+ final_output_schema = None
375
+ elif output_schema is not None:
376
+ # Explicit schema provided - use as-is
377
+ final_output_schema = output_schema
378
+ else:
379
+ # Smart fallback: try custom function, then parent, then None
380
+ if transform_fn is not None:
381
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
382
+ final_output_schema = _wrap_schema_if_needed(parsed_fn.output_schema)
383
+ if final_output_schema is None:
384
+ final_output_schema = tool.output_schema
385
+ else:
386
+ final_output_schema = tool.output_schema
387
+
388
  if transform_fn is None:
389
  # User wants pure transformation - use forwarding_fn as the main function
390
  final_fn = forwarding_fn
391
  final_schema = schema
392
  else:
393
  # User provided custom function - merge schemas
394
+ if "parsed_fn" not in locals():
395
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
396
  final_fn = transform_fn
397
 
398
  has_kwargs = cls._function_has_kwargs(transform_fn)
 
460
  name=name or tool.name,
461
  description=final_description,
462
  parameters=final_schema,
463
+ output_schema=final_output_schema,
464
  tags=tags or tool.tags,
465
  annotations=annotations or tool.annotations,
466
  serializer=serializer or tool.serializer,
tests/tools/test_tool.py CHANGED
@@ -31,12 +31,10 @@ class TestToolFromFunction:
31
  assert len(tool.parameters["properties"]) == 2
32
  assert tool.parameters["properties"]["a"]["type"] == "integer"
33
  assert tool.parameters["properties"]["b"]["type"] == "integer"
34
- # With primitive wrapping, int return type becomes object with value property
35
  expected_schema = {
36
  "type": "object",
37
- "properties": {"value": {"title": "Value", "type": "integer"}},
38
- "required": ["value"],
39
- "title": "Result",
40
  "x-fastmcp-wrap-result": True,
41
  }
42
  assert tool.output_schema == expected_schema
@@ -251,7 +249,7 @@ class TestToolFromFunction:
251
  assert isinstance(result.content[0], TextContent)
252
  assert result.content[0].text == "Custom serializer: 15"
253
  # Structured output should have the raw value
254
- assert result.structured_content == {"value": 15}
255
 
256
 
257
  class TestToolFromFunctionOutputSchema:
@@ -287,27 +285,20 @@ class TestToolFromFunctionOutputSchema:
287
 
288
  base_schema = TypeAdapter(annotation).json_schema()
289
 
290
- # Only pure primitives (just type + optional title) get wrapped
291
- primitive_types = {"string", "number", "integer", "boolean", "null"}
292
  schema_type = base_schema.get("type")
293
- is_pure_primitive = (
294
- schema_type in primitive_types
295
- and len(base_schema) <= 2 # Only 'type' and optionally 'title'
296
- and all(key in {"type", "title"} for key in base_schema.keys())
297
- )
298
 
299
- if is_pure_primitive:
300
- # Pure primitives get wrapped
301
  expected_schema = {
302
  "type": "object",
303
- "properties": {"value": base_schema | {"title": "Value"}},
304
- "required": ["value"],
305
- "title": "Result",
306
  "x-fastmcp-wrap-result": True,
307
  }
308
  assert tool.output_schema == expected_schema
309
  else:
310
- # Complex types (objects, unions, constrained types) remain unwrapped
311
  assert tool.output_schema == base_schema
312
 
313
  @pytest.mark.parametrize(
@@ -326,8 +317,17 @@ class TestToolFromFunctionOutputSchema:
326
  tool = Tool.from_function(func)
327
  base_schema = TypeAdapter(annotation).json_schema()
328
 
329
- # Complex types with constraints are not wrapped - they remain as-is
330
- assert tool.output_schema == base_schema
 
 
 
 
 
 
 
 
 
331
 
332
  @pytest.mark.parametrize(
333
  "annotation, expected",
 
31
  assert len(tool.parameters["properties"]) == 2
32
  assert tool.parameters["properties"]["a"]["type"] == "integer"
33
  assert tool.parameters["properties"]["b"]["type"] == "integer"
34
+ # With primitive wrapping, int return type becomes object with result property
35
  expected_schema = {
36
  "type": "object",
37
+ "properties": {"result": {"type": "integer"}},
 
 
38
  "x-fastmcp-wrap-result": True,
39
  }
40
  assert tool.output_schema == expected_schema
 
249
  assert isinstance(result.content[0], TextContent)
250
  assert result.content[0].text == "Custom serializer: 15"
251
  # Structured output should have the raw value
252
+ assert result.structured_content == {"result": 15}
253
 
254
 
255
  class TestToolFromFunctionOutputSchema:
 
285
 
286
  base_schema = TypeAdapter(annotation).json_schema()
287
 
288
+ # Non-object types get wrapped
 
289
  schema_type = base_schema.get("type")
290
+ is_object_type = schema_type == "object"
 
 
 
 
291
 
292
+ if not is_object_type:
293
+ # Non-object types get wrapped
294
  expected_schema = {
295
  "type": "object",
296
+ "properties": {"result": base_schema},
 
 
297
  "x-fastmcp-wrap-result": True,
298
  }
299
  assert tool.output_schema == expected_schema
300
  else:
301
+ # Object types remain unwrapped
302
  assert tool.output_schema == base_schema
303
 
304
  @pytest.mark.parametrize(
 
317
  tool = Tool.from_function(func)
318
  base_schema = TypeAdapter(annotation).json_schema()
319
 
320
+ # Special case for Any type - it generates an empty schema and doesn't get wrapped
321
+ if annotation is Any:
322
+ assert tool.output_schema == base_schema # Should be {}
323
+ else:
324
+ # All other non-object types get wrapped, including complex constrained types
325
+ expected_schema = {
326
+ "type": "object",
327
+ "properties": {"result": base_schema},
328
+ "x-fastmcp-wrap-result": True,
329
+ }
330
+ assert tool.output_schema == expected_schema
331
 
332
  @pytest.mark.parametrize(
333
  "annotation, expected",
tests/tools/test_tool_transform.py CHANGED
@@ -255,7 +255,7 @@ async def test_forward_raw_without_argument_mapping(add_tool):
255
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
256
  async def custom_fn(extra: int, **kwargs) -> int:
257
  sum = await forward(**kwargs)
258
- return int(sum[0].text) + extra # type: ignore[attr-defined]
259
 
260
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
261
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
@@ -360,7 +360,7 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
360
  ) # drop 'old_y'
361
  result = await new_tool.run(arguments={"new_x": 8})
362
  # 8 + 10 (default value of b in parent)
363
- assert result[0].text == "18" # type: ignore[attr-defined]
364
 
365
 
366
  async def test_forward_outside_context_raises_error():
@@ -480,18 +480,18 @@ async def test_tool_transform_chaining(add_tool):
480
  tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
481
 
482
  result = await tool2.run(arguments={"final_x": 5})
483
- assert result[0].text == "15" # type: ignore[attr-defined]
484
 
485
  # Transform tool1 with custom function that handles all parameters
486
  async def custom(final_x: int, **kwargs) -> str:
487
  result = await forward(final_x=final_x, **kwargs)
488
- return f"custom {result[0].text}" # Extract text from content
489
 
490
  tool3 = Tool.from_tool(
491
  tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
492
  )
493
  result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
494
- assert result[0].text == "custom 8" # type: ignore[attr-defined]
495
 
496
 
497
  class MyModel(BaseModel):
@@ -619,7 +619,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
619
  # Function signature has different types/defaults than ArgTransform
620
  async def custom_fn(x: str = "function_default", **kwargs) -> str:
621
  result = await forward(x=x, **kwargs)
622
- return f"custom: {result}"
623
 
624
  tool = Tool.from_tool(
625
  base,
@@ -646,7 +646,7 @@ async def test_arg_transform_precedence_over_function_with_kwargs():
646
  # Test it works at runtime
647
  result = await tool.run(arguments={"y": "test"})
648
  # Should use ArgTransform default of 42
649
- assert "42: test" in result[0].text # type: ignore[attr-defined]
650
 
651
 
652
  def test_arg_transform_combined_attributes():
@@ -691,7 +691,7 @@ async def test_arg_transform_type_precedence_runtime():
691
  # Convert string back to int for the original function
692
  result = await forward_raw(x=int(x), y=y)
693
  # Extract the text from the result
694
- result_text = result[0].text
695
  return f"String input '{x}' converted to result: {result_text}"
696
 
697
  tool = Tool.from_tool(
@@ -703,8 +703,8 @@ async def test_arg_transform_type_precedence_runtime():
703
 
704
  # Test it works with string input
705
  result = await tool.run(arguments={"x": "5", "y": 3})
706
- assert "String input '5'" in result[0].text # type: ignore[attr-defined]
707
- assert "result: 8" in result[0].text # type: ignore[attr-defined]
708
 
709
 
710
  class TestProxy:
@@ -739,7 +739,7 @@ class TestProxy:
739
  async with Client(proxy_server) as client:
740
  # The tool should be registered with its transformed name
741
  result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
742
- assert result[0].text == "3" # type: ignore[attr-defined]
743
 
744
 
745
  async def test_arg_transform_default_factory():
@@ -762,7 +762,7 @@ async def test_arg_transform_default_factory():
762
 
763
  # Should work without providing timestamp (gets value from factory)
764
  result = await new_tool.run(arguments={"x": 42})
765
- assert result[0].text == "42_12345.0" # type: ignore[attr-defined]
766
 
767
 
768
  async def test_arg_transform_default_factory_called_each_time():
@@ -790,11 +790,11 @@ async def test_arg_transform_default_factory_called_each_time():
790
 
791
  # First call
792
  result1 = await new_tool.run(arguments={"x": 1})
793
- assert result1[0].text == "1_1" # type: ignore[attr-defined]
794
 
795
  # Second call should get a different value
796
  result2 = await new_tool.run(arguments={"x": 2})
797
- assert result2[0].text == "2_2" # type: ignore[attr-defined]
798
 
799
 
800
  async def test_arg_transform_hidden_with_default_factory():
@@ -819,7 +819,7 @@ async def test_arg_transform_hidden_with_default_factory():
819
 
820
  # Should pass hidden request_id with factory value
821
  result = await new_tool.run(arguments={"x": 42})
822
- assert result[0].text == "42_req_123" # type: ignore[attr-defined]
823
 
824
 
825
  async def test_arg_transform_default_and_factory_raises_error():
@@ -856,7 +856,7 @@ async def test_arg_transform_required_true():
856
 
857
  # Should work when parameter is provided
858
  result = await new_tool.run(arguments={"optional_param": 100})
859
- assert result[0].text == "value: 100" # type: ignore
860
 
861
  # Should fail when parameter is not provided
862
  with pytest.raises(TypeError, match="Missing required argument"):
@@ -903,7 +903,7 @@ async def test_arg_transform_required_with_rename():
903
 
904
  # Should work with new name
905
  result = await new_tool.run(arguments={"new_param": 200})
906
- assert result[0].text == "value: 200" # type: ignore
907
 
908
 
909
  async def test_arg_transform_required_true_with_default_raises_error():
@@ -945,7 +945,7 @@ async def test_arg_transform_required_no_change():
945
 
946
  # Should work as expected
947
  result = await new_tool.run(arguments={"req": 1})
948
- assert result[0].text == "values: 1, 42" # type: ignore
949
 
950
 
951
  async def test_arg_transform_hide_and_required_raises_error():
@@ -977,7 +977,7 @@ class TestEnableDisable:
977
  assert {tool.name for tool in tools} == {"new_add"}
978
 
979
  result = await client.call_tool("new_add", {"x": 1, "y": 2})
980
- assert result[0].text == "3" # type: ignore[attr-defined]
981
 
982
  with pytest.raises(ToolError):
983
  await client.call_tool("add", {"x": 1, "y": 2})
 
255
  async def test_custom_fn_with_kwargs_and_no_transform_args(add_tool):
256
  async def custom_fn(extra: int, **kwargs) -> int:
257
  sum = await forward(**kwargs)
258
+ return int(sum.content[0].text) + extra # type: ignore[attr-defined]
259
 
260
  new_tool = Tool.from_tool(add_tool, transform_fn=custom_fn)
261
  result = await new_tool.run(arguments={"extra": 1, "old_x": 2, "old_y": 3})
 
360
  ) # drop 'old_y'
361
  result = await new_tool.run(arguments={"new_x": 8})
362
  # 8 + 10 (default value of b in parent)
363
+ assert result.content[0].text == "18" # type: ignore[attr-defined]
364
 
365
 
366
  async def test_forward_outside_context_raises_error():
 
480
  tool2 = Tool.from_tool(tool1, transform_args={"x": ArgTransform(name="final_x")})
481
 
482
  result = await tool2.run(arguments={"final_x": 5})
483
+ assert result.content[0].text == "15" # type: ignore[attr-defined]
484
 
485
  # Transform tool1 with custom function that handles all parameters
486
  async def custom(final_x: int, **kwargs) -> str:
487
  result = await forward(final_x=final_x, **kwargs)
488
+ return f"custom {result.content[0].text}" # Extract text from content
489
 
490
  tool3 = Tool.from_tool(
491
  tool1, transform_fn=custom, transform_args={"x": ArgTransform(name="final_x")}
492
  )
493
  result = await tool3.run(arguments={"final_x": 3, "old_y": 5})
494
+ assert result.content[0].text == "custom 8" # type: ignore[attr-defined]
495
 
496
 
497
  class MyModel(BaseModel):
 
619
  # Function signature has different types/defaults than ArgTransform
620
  async def custom_fn(x: str = "function_default", **kwargs) -> str:
621
  result = await forward(x=x, **kwargs)
622
+ return f"custom: {result.content[0].text}"
623
 
624
  tool = Tool.from_tool(
625
  base,
 
646
  # Test it works at runtime
647
  result = await tool.run(arguments={"y": "test"})
648
  # Should use ArgTransform default of 42
649
+ assert "42: test" in result.content[0].text # type: ignore[attr-defined]
650
 
651
 
652
  def test_arg_transform_combined_attributes():
 
691
  # Convert string back to int for the original function
692
  result = await forward_raw(x=int(x), y=y)
693
  # Extract the text from the result
694
+ result_text = result.content[0].text
695
  return f"String input '{x}' converted to result: {result_text}"
696
 
697
  tool = Tool.from_tool(
 
703
 
704
  # Test it works with string input
705
  result = await tool.run(arguments={"x": "5", "y": 3})
706
+ assert "String input '5'" in result.content[0].text # type: ignore[attr-defined]
707
+ assert "result: 8" in result.content[0].text # type: ignore[attr-defined]
708
 
709
 
710
  class TestProxy:
 
739
  async with Client(proxy_server) as client:
740
  # The tool should be registered with its transformed name
741
  result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
742
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
743
 
744
 
745
  async def test_arg_transform_default_factory():
 
762
 
763
  # Should work without providing timestamp (gets value from factory)
764
  result = await new_tool.run(arguments={"x": 42})
765
+ assert result.content[0].text == "42_12345.0" # type: ignore[attr-defined]
766
 
767
 
768
  async def test_arg_transform_default_factory_called_each_time():
 
790
 
791
  # First call
792
  result1 = await new_tool.run(arguments={"x": 1})
793
+ assert result1.content[0].text == "1_1" # type: ignore[attr-defined]
794
 
795
  # Second call should get a different value
796
  result2 = await new_tool.run(arguments={"x": 2})
797
+ assert result2.content[0].text == "2_2" # type: ignore[attr-defined]
798
 
799
 
800
  async def test_arg_transform_hidden_with_default_factory():
 
819
 
820
  # Should pass hidden request_id with factory value
821
  result = await new_tool.run(arguments={"x": 42})
822
+ assert result.content[0].text == "42_req_123" # type: ignore[attr-defined]
823
 
824
 
825
  async def test_arg_transform_default_and_factory_raises_error():
 
856
 
857
  # Should work when parameter is provided
858
  result = await new_tool.run(arguments={"optional_param": 100})
859
+ assert result.content[0].text == "value: 100" # type: ignore
860
 
861
  # Should fail when parameter is not provided
862
  with pytest.raises(TypeError, match="Missing required argument"):
 
903
 
904
  # Should work with new name
905
  result = await new_tool.run(arguments={"new_param": 200})
906
+ assert result.content[0].text == "value: 200" # type: ignore
907
 
908
 
909
  async def test_arg_transform_required_true_with_default_raises_error():
 
945
 
946
  # Should work as expected
947
  result = await new_tool.run(arguments={"req": 1})
948
+ assert result.content[0].text == "values: 1, 42" # type: ignore
949
 
950
 
951
  async def test_arg_transform_hide_and_required_raises_error():
 
977
  assert {tool.name for tool in tools} == {"new_add"}
978
 
979
  result = await client.call_tool("new_add", {"x": 1, "y": 2})
980
+ assert result.content[0].text == "3" # type: ignore[attr-defined]
981
 
982
  with pytest.raises(ToolError):
983
  await client.call_tool("add", {"x": 1, "y": 2})