Jeremiah Lowin commited on
Commit
d0abf98
·
unverified ·
1 Parent(s): 7712403

Ensure transformed tools generate structured content (#1443)

Browse files
src/fastmcp/tools/tool.py CHANGED
@@ -8,7 +8,6 @@ from typing import (
8
  Annotated,
9
  Any,
10
  Generic,
11
- Literal,
12
  TypeVar,
13
  get_type_hints,
14
  )
@@ -163,7 +162,7 @@ class Tool(FastMCPComponent):
163
  tags: set[str] | None = None,
164
  annotations: ToolAnnotations | None = None,
165
  exclude_args: list[str] | None = None,
166
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
167
  serializer: Callable[[Any], str] | None = None,
168
  meta: dict[str, Any] | None = None,
169
  enabled: bool | None = None,
@@ -199,17 +198,18 @@ class Tool(FastMCPComponent):
199
  def from_tool(
200
  cls,
201
  tool: Tool,
202
- transform_fn: Callable[..., Any] | None = None,
203
  name: str | None = None,
204
  title: str | None | NotSetT = NotSet,
205
- transform_args: dict[str, ArgTransform] | None = None,
206
  description: str | None | NotSetT = NotSet,
207
  tags: set[str] | None = None,
208
- annotations: ToolAnnotations | None = None,
209
- output_schema: dict[str, Any] | None | Literal[False] = None,
210
  serializer: Callable[[Any], str] | None = None,
211
  meta: dict[str, Any] | None | NotSetT = NotSet,
 
212
  enabled: bool | None = None,
 
213
  ) -> TransformedTool:
214
  from fastmcp.tools.tool_transform import TransformedTool
215
 
@@ -242,7 +242,7 @@ class FunctionTool(Tool):
242
  tags: set[str] | None = None,
243
  annotations: ToolAnnotations | None = None,
244
  exclude_args: list[str] | None = None,
245
- output_schema: dict[str, Any] | None | NotSetT | Literal[False] = NotSet,
246
  serializer: Callable[[Any], str] | None = None,
247
  meta: dict[str, Any] | None = None,
248
  enabled: bool | None = None,
 
8
  Annotated,
9
  Any,
10
  Generic,
 
11
  TypeVar,
12
  get_type_hints,
13
  )
 
162
  tags: set[str] | None = None,
163
  annotations: ToolAnnotations | None = None,
164
  exclude_args: list[str] | None = None,
165
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
166
  serializer: Callable[[Any], str] | None = None,
167
  meta: dict[str, Any] | None = None,
168
  enabled: bool | None = None,
 
198
  def from_tool(
199
  cls,
200
  tool: Tool,
201
+ *,
202
  name: str | None = None,
203
  title: str | None | NotSetT = NotSet,
 
204
  description: str | None | NotSetT = NotSet,
205
  tags: set[str] | None = None,
206
+ annotations: ToolAnnotations | None | NotSetT = NotSet,
207
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
208
  serializer: Callable[[Any], str] | None = None,
209
  meta: dict[str, Any] | None | NotSetT = NotSet,
210
+ transform_args: dict[str, ArgTransform] | None = None,
211
  enabled: bool | None = None,
212
+ transform_fn: Callable[..., Any] | None = None,
213
  ) -> TransformedTool:
214
  from fastmcp.tools.tool_transform import TransformedTool
215
 
 
242
  tags: set[str] | None = None,
243
  annotations: ToolAnnotations | None = None,
244
  exclude_args: list[str] | None = None,
245
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
246
  serializer: Callable[[Any], str] | None = None,
247
  meta: dict[str, Any] | None = None,
248
  enabled: bool | None = None,
src/fastmcp/tools/tool_transform.py CHANGED
@@ -6,6 +6,7 @@ from contextvars import ContextVar
6
  from dataclasses import dataclass
7
  from typing import Annotated, Any, Literal
8
 
 
9
  from mcp.types import ToolAnnotations
10
  from pydantic import ConfigDict
11
  from pydantic.fields import Field
@@ -312,11 +313,9 @@ class TransformedTool(Tool):
312
  # Custom function returns ToolResult - preserve its content
313
  return result
314
  else:
315
- # Forwarded call with disabled schema - strip structured content
316
- return ToolResult(
317
- content=result.content,
318
- structured_content=None,
319
- )
320
  elif self.output_schema.get(
321
  "type"
322
  ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
@@ -334,17 +333,23 @@ class TransformedTool(Tool):
334
  result, serializer=self.serializer
335
  )
336
 
337
- # Handle structured content based on output schema
 
338
  if self.output_schema is not None:
339
  if self.output_schema.get("x-fastmcp-wrap-result"):
340
  # Schema says wrap - always wrap in result key
341
  structured_output = {"result": result}
342
  else:
343
- # Object schemas - use result directly
344
- # User is responsible for returning dict-compatible data
345
  structured_output = result
346
- else:
347
- structured_output = None
 
 
 
 
 
 
 
348
 
349
  return ToolResult(
350
  content=unstructured_result,
@@ -363,9 +368,9 @@ class TransformedTool(Tool):
363
  tags: set[str] | None = None,
364
  transform_fn: Callable[..., Any] | None = None,
365
  transform_args: dict[str, ArgTransform] | None = None,
366
- annotations: ToolAnnotations | None = None,
367
- output_schema: dict[str, Any] | None | Literal[False] = None,
368
- serializer: Callable[[Any], str] | None = None,
369
  meta: dict[str, Any] | None | NotSetT = NotSet,
370
  enabled: bool | None = None,
371
  ) -> TransformedTool:
@@ -445,6 +450,11 @@ class TransformedTool(Tool):
445
  """
446
  transform_args = transform_args or {}
447
 
 
 
 
 
 
448
  # Validate transform_args
449
  parent_params = set(tool.parameters.get("properties", {}).keys())
450
  unknown_args = set(transform_args.keys()) - parent_params
@@ -457,16 +467,11 @@ class TransformedTool(Tool):
457
  # Always create the forwarding transform
458
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
459
 
460
- # Handle output schema with smart fallback
461
- if output_schema is False:
462
- final_output_schema = None
463
- elif output_schema is not None:
464
- # Explicit schema provided - use as-is
465
- final_output_schema = output_schema
466
- else:
467
- # Smart fallback: try custom function, then parent, then None
468
  if transform_fn is not None:
469
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
470
  final_output_schema = parsed_fn.output_schema
471
  if final_output_schema is None:
472
  # Check if function returns ToolResult - if so, don't fall back to parent
@@ -479,15 +484,17 @@ class TransformedTool(Tool):
479
  final_output_schema = tool.output_schema
480
  else:
481
  final_output_schema = tool.output_schema
 
 
 
482
 
483
  if transform_fn is None:
484
  # User wants pure transformation - use forwarding_fn as the main function
485
  final_fn = forwarding_fn
486
  final_schema = schema
487
  else:
 
488
  # User provided custom function - merge schemas
489
- if "parsed_fn" not in locals():
490
- parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
491
  final_fn = transform_fn
492
 
493
  has_kwargs = cls._function_has_kwargs(transform_fn)
@@ -552,6 +559,13 @@ class TransformedTool(Tool):
552
  )
553
  final_title = title if not isinstance(title, NotSetT) else tool.title
554
  final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
 
 
 
 
 
 
 
555
 
556
  transformed_tool = cls(
557
  fn=final_fn,
@@ -563,11 +577,11 @@ class TransformedTool(Tool):
563
  parameters=final_schema,
564
  output_schema=final_output_schema,
565
  tags=tags or tool.tags,
566
- annotations=annotations or tool.annotations,
567
- serializer=serializer or tool.serializer,
568
  meta=final_meta,
569
  transform_args=transform_args,
570
- enabled=enabled if enabled is not None else True,
571
  )
572
 
573
  return transformed_tool
 
6
  from dataclasses import dataclass
7
  from typing import Annotated, Any, Literal
8
 
9
+ import pydantic_core
10
  from mcp.types import ToolAnnotations
11
  from pydantic import ConfigDict
12
  from pydantic.fields import Field
 
313
  # Custom function returns ToolResult - preserve its content
314
  return result
315
  else:
316
+ # Forwarded call with no explicit schema - preserve parent's structured content
317
+ # The parent tool may have generated structured content via its own fallback logic
318
+ return result
 
 
319
  elif self.output_schema.get(
320
  "type"
321
  ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"):
 
333
  result, serializer=self.serializer
334
  )
335
 
336
+ structured_output = None
337
+ # First handle structured content based on output schema, if any
338
  if self.output_schema is not None:
339
  if self.output_schema.get("x-fastmcp-wrap-result"):
340
  # Schema says wrap - always wrap in result key
341
  structured_output = {"result": result}
342
  else:
 
 
343
  structured_output = result
344
+ # If no output schema, try to serialize the result. If it is a dict, use
345
+ # it as structured content. If it is not a dict, ignore it.
346
+ if structured_output is None:
347
+ try:
348
+ structured_output = pydantic_core.to_jsonable_python(result)
349
+ if not isinstance(structured_output, dict):
350
+ structured_output = None
351
+ except Exception:
352
+ pass
353
 
354
  return ToolResult(
355
  content=unstructured_result,
 
368
  tags: set[str] | None = None,
369
  transform_fn: Callable[..., Any] | None = None,
370
  transform_args: dict[str, ArgTransform] | None = None,
371
+ annotations: ToolAnnotations | None | NotSetT = NotSet,
372
+ output_schema: dict[str, Any] | None | NotSetT = NotSet,
373
+ serializer: Callable[[Any], str] | None | NotSetT = NotSet,
374
  meta: dict[str, Any] | None | NotSetT = NotSet,
375
  enabled: bool | None = None,
376
  ) -> TransformedTool:
 
450
  """
451
  transform_args = transform_args or {}
452
 
453
+ if transform_fn is not None:
454
+ parsed_fn = ParsedFunction.from_function(transform_fn, validate=False)
455
+ else:
456
+ parsed_fn = None
457
+
458
  # Validate transform_args
459
  parent_params = set(tool.parameters.get("properties", {}).keys())
460
  unknown_args = set(transform_args.keys()) - parent_params
 
467
  # Always create the forwarding transform
468
  schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args)
469
 
470
+ # Handle output schema
471
+ if output_schema is NotSet:
472
+ # Use smart fallback: try custom function, then parent
 
 
 
 
 
473
  if transform_fn is not None:
474
+ assert parsed_fn is not None
475
  final_output_schema = parsed_fn.output_schema
476
  if final_output_schema is None:
477
  # Check if function returns ToolResult - if so, don't fall back to parent
 
484
  final_output_schema = tool.output_schema
485
  else:
486
  final_output_schema = tool.output_schema
487
+ else:
488
+ assert isinstance(output_schema, dict | None)
489
+ final_output_schema = output_schema
490
 
491
  if transform_fn is None:
492
  # User wants pure transformation - use forwarding_fn as the main function
493
  final_fn = forwarding_fn
494
  final_schema = schema
495
  else:
496
+ assert parsed_fn is not None
497
  # User provided custom function - merge schemas
 
 
498
  final_fn = transform_fn
499
 
500
  has_kwargs = cls._function_has_kwargs(transform_fn)
 
559
  )
560
  final_title = title if not isinstance(title, NotSetT) else tool.title
561
  final_meta = meta if not isinstance(meta, NotSetT) else tool.meta
562
+ final_annotations = (
563
+ annotations if not isinstance(annotations, NotSetT) else tool.annotations
564
+ )
565
+ final_serializer = (
566
+ serializer if not isinstance(serializer, NotSetT) else tool.serializer
567
+ )
568
+ final_enabled = enabled if enabled is not None else tool.enabled
569
 
570
  transformed_tool = cls(
571
  fn=final_fn,
 
577
  parameters=final_schema,
578
  output_schema=final_output_schema,
579
  tags=tags or tool.tags,
580
+ annotations=final_annotations,
581
+ serializer=final_serializer,
582
  meta=final_meta,
583
  transform_args=transform_args,
584
+ enabled=final_enabled,
585
  )
586
 
587
  return transformed_tool
tests/server/test_tool_transformation.py CHANGED
@@ -38,3 +38,31 @@ async def test_transformed_tool_filtering():
38
 
39
  tools = list(await mcp._list_tools())
40
  assert len(tools) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  tools = list(await mcp._list_tools())
40
  assert len(tools) == 1
41
+
42
+
43
+ async def test_transformed_tool_structured_output_without_annotation():
44
+ """Test that transformed tools generate structured output when original tool has no return annotation.
45
+
46
+ Ref: https://github.com/jlowin/fastmcp/issues/1369
47
+ """
48
+ from fastmcp.client import Client
49
+
50
+ mcp = FastMCP("Test Server")
51
+
52
+ @mcp.tool()
53
+ def tool_without_annotation(message: str): # No return annotation
54
+ """A tool without return type annotation."""
55
+ return {"result": "processed", "input": message}
56
+
57
+ # Create a transformed tool
58
+ mcp.add_tool_transformation(
59
+ "tool_without_annotation", ToolTransformConfig(name="transformed_tool")
60
+ )
61
+
62
+ # Test with client to verify structured output is populated
63
+ async with Client(mcp) as client:
64
+ result = await client.call_tool("transformed_tool", {"message": "test"})
65
+
66
+ # Structured output should be populated even without return annotation
67
+ assert result.data is not None
68
+ assert result.data == {"result": "processed", "input": "test"}
tests/tools/test_tool.py CHANGED
@@ -562,7 +562,7 @@ class TestToolFromFunctionOutputSchema:
562
  def func() -> dict[str, str]:
563
  return {"message": "Hello, world!"}
564
 
565
- tool = Tool.from_function(func, output_schema=False)
566
  assert tool.output_schema is None
567
 
568
  result = await tool.run({})
@@ -729,17 +729,23 @@ class TestToolFromFunctionOutputSchema:
729
  tool_none = Tool.from_function(func, output_schema=None)
730
  assert tool_none.output_schema is None
731
 
732
- # False should also disable
733
- tool_false = Tool.from_function(func, output_schema=False)
734
- assert tool_false.output_schema is None
 
 
735
 
736
- # Both should have same behavior
737
  result_none = await tool_none.run({})
738
- result_false = await tool_false.run({})
739
 
740
- assert result_none.structured_content is None
741
- assert result_false.structured_content is None
742
- assert result_none.content[0].text == result_false.content[0].text == "123" # type: ignore[attr-defined]
 
 
 
 
743
 
744
  async def test_non_object_output_schema_raises_error(self):
745
  """Test that providing a non-object output schema raises a ValueError."""
@@ -1117,7 +1123,7 @@ class TestAutomaticStructuredContent:
1117
  return UserProfile(name="Bob", age=25, email="bob@example.com")
1118
 
1119
  # No explicit output schema, but dataclass should still create structured content
1120
- tool = Tool.from_function(get_profile, output_schema=False)
1121
 
1122
  result = await tool.run({"user_id": "456"})
1123
 
@@ -1144,8 +1150,8 @@ class TestAutomaticStructuredContent:
1144
  def get_user_stats(user_id: str) -> UserData:
1145
  return UserData(username="charlie", score=100, verified=True)
1146
 
1147
- # Explicitly disable output schema to test automatic structured content
1148
- tool = Tool.from_function(get_user_stats, output_schema=False)
1149
 
1150
  result = await tool.run({"user_id": "789"})
1151
 
 
562
  def func() -> dict[str, str]:
563
  return {"message": "Hello, world!"}
564
 
565
+ tool = Tool.from_function(func, output_schema=None)
566
  assert tool.output_schema is None
567
 
568
  result = await tool.run({})
 
729
  tool_none = Tool.from_function(func, output_schema=None)
730
  assert tool_none.output_schema is None
731
 
732
+ # Default (NotSet) should infer from return type
733
+ tool_default = Tool.from_function(func)
734
+ assert (
735
+ tool_default.output_schema is not None
736
+ ) # Should infer schema from dict return type
737
 
738
+ # Different behavior: None vs inferred
739
  result_none = await tool_none.run({})
740
+ result_default = await tool_default.run({})
741
 
742
+ # None should still try fallback generation but fail for non-dict
743
+ assert result_none.structured_content is None # Fallback fails for int
744
+ # Default should use proper schema and wrap the result
745
+ assert result_default.structured_content == {
746
+ "result": 123
747
+ } # Schema-based generation with wrapping
748
+ assert result_none.content[0].text == result_default.content[0].text == "123" # type: ignore[attr-defined]
749
 
750
  async def test_non_object_output_schema_raises_error(self):
751
  """Test that providing a non-object output schema raises a ValueError."""
 
1123
  return UserProfile(name="Bob", age=25, email="bob@example.com")
1124
 
1125
  # No explicit output schema, but dataclass should still create structured content
1126
+ tool = Tool.from_function(get_profile, output_schema=None)
1127
 
1128
  result = await tool.run({"user_id": "456"})
1129
 
 
1150
  def get_user_stats(user_id: str) -> UserData:
1151
  return UserData(username="charlie", score=100, verified=True)
1152
 
1153
+ # Explicitly set output schema to None to test automatic structured content
1154
+ tool = Tool.from_function(get_user_stats, output_schema=None)
1155
 
1156
  result = await tool.run({"user_id": "789"})
1157
 
tests/tools/test_tool_transform.py CHANGED
@@ -1020,7 +1020,12 @@ class TestEnableDisable:
1020
  new_add = Tool.from_tool(add, name="new_add")
1021
  mcp.add_tool(new_add)
1022
 
1023
- assert new_add.enabled
 
 
 
 
 
1024
 
1025
  async with Client(mcp) as client:
1026
  tools = await client.list_tools()
@@ -1118,15 +1123,15 @@ class TestTransformToolOutputSchema:
1118
  assert new_tool.output_schema == expected_schema
1119
  assert new_tool.output_schema == base_string_tool.output_schema
1120
 
1121
- def test_transform_with_explicit_output_schema_false(self, base_string_tool):
1122
- """Test that output_schema=False disables structured output."""
1123
- new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1124
 
1125
  assert new_tool.output_schema is None
1126
 
1127
- async def test_transform_output_schema_false_runtime(self, base_string_tool):
1128
- """Test runtime behavior with output_schema=False."""
1129
- new_tool = Tool.from_tool(base_string_tool, output_schema=False)
1130
 
1131
  # Debug: check that output_schema is actually None
1132
  assert new_tool.output_schema is None, (
@@ -1134,7 +1139,8 @@ class TestTransformToolOutputSchema:
1134
  )
1135
 
1136
  result = await new_tool.run({"x": 5})
1137
- assert result.structured_content is None
 
1138
  assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
1139
 
1140
  def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
@@ -1306,25 +1312,27 @@ class TestTransformToolOutputSchema:
1306
  expected_schema = TypeAdapter(dict[str, str]).json_schema()
1307
  assert new_tool.output_schema == expected_schema
1308
 
1309
- async def test_transform_output_schema_none_vs_false(self, base_string_tool):
1310
- """Test None vs False behavior for output_schema in transforms."""
1311
- # None (default) should use smart fallback (inherit from parent)
1312
- tool_none = Tool.from_tool(base_string_tool) # default output_schema=None
1313
- assert tool_none.output_schema == base_string_tool.output_schema # Inherits
1314
 
1315
- # False should explicitly disable
1316
- tool_false = Tool.from_tool(base_string_tool, output_schema=False)
1317
- assert tool_false.output_schema is None
1318
 
1319
- # Different behavior at runtime
1320
- result_none = await tool_none.run({"x": 5})
1321
- result_false = await tool_false.run({"x": 5})
1322
 
1323
- assert result_none.structured_content == {
1324
  "result": "Result: 5"
1325
  } # Inherits wrapping
1326
- assert result_false.structured_content is None # Disabled
1327
- assert result_none.content[0].text == result_false.content[0].text # type: ignore[attr-defined]
 
 
1328
 
1329
  async def test_transform_output_schema_with_tool_result_return(
1330
  self, base_string_tool
 
1020
  new_add = Tool.from_tool(add, name="new_add")
1021
  mcp.add_tool(new_add)
1022
 
1023
+ # the new tool inherits the disabled state from the parent tool
1024
+ assert new_add.enabled is False
1025
+
1026
+ new_add.enable()
1027
+ assert new_add.enabled is True
1028
+ assert add.enabled is False
1029
 
1030
  async with Client(mcp) as client:
1031
  tools = await client.list_tools()
 
1123
  assert new_tool.output_schema == expected_schema
1124
  assert new_tool.output_schema == base_string_tool.output_schema
1125
 
1126
+ def test_transform_with_explicit_output_schema_none(self, base_string_tool):
1127
+ """Test that output_schema=None sets output schema to None."""
1128
+ new_tool = Tool.from_tool(base_string_tool, output_schema=None)
1129
 
1130
  assert new_tool.output_schema is None
1131
 
1132
+ async def test_transform_output_schema_none_runtime(self, base_string_tool):
1133
+ """Test runtime behavior with output_schema=None."""
1134
+ new_tool = Tool.from_tool(base_string_tool, output_schema=None)
1135
 
1136
  # Debug: check that output_schema is actually None
1137
  assert new_tool.output_schema is None, (
 
1139
  )
1140
 
1141
  result = await new_tool.run({"x": 5})
1142
+ # Even with output_schema=None, structured content should be generated via fallback logic
1143
+ assert result.structured_content == {"result": "Result: 5"}
1144
  assert result.content[0].text == "Result: 5" # type: ignore[attr-defined]
1145
 
1146
  def test_transform_with_explicit_output_schema_dict(self, base_string_tool):
 
1312
  expected_schema = TypeAdapter(dict[str, str]).json_schema()
1313
  assert new_tool.output_schema == expected_schema
1314
 
1315
+ async def test_transform_output_schema_default_vs_none(self, base_string_tool):
1316
+ """Test default (NotSet) vs explicit None behavior for output_schema in transforms."""
1317
+ # Default (NotSet) should use smart fallback (inherit from parent)
1318
+ tool_default = Tool.from_tool(base_string_tool) # default output_schema=NotSet
1319
+ assert tool_default.output_schema == base_string_tool.output_schema # Inherits
1320
 
1321
+ # None should explicitly set output_schema to None but still generate structured content via fallback
1322
+ tool_explicit_none = Tool.from_tool(base_string_tool, output_schema=None)
1323
+ assert tool_explicit_none.output_schema is None
1324
 
1325
+ # Both should generate structured content now (via different paths)
1326
+ result_default = await tool_default.run({"x": 5})
1327
+ result_explicit_none = await tool_explicit_none.run({"x": 5})
1328
 
1329
+ assert result_default.structured_content == {
1330
  "result": "Result: 5"
1331
  } # Inherits wrapping
1332
+ assert result_explicit_none.structured_content == {
1333
+ "result": "Result: 5"
1334
+ } # Generated via fallback logic
1335
+ assert result_default.content[0].text == result_explicit_none.content[0].text # type: ignore[attr-defined]
1336
 
1337
  async def test_transform_output_schema_with_tool_result_return(
1338
  self, base_string_tool