Skip to content

Commit d0bcec9

Browse files
strawgateclaudejlowinJeremiah Lowin
authored
fix: TransformedTool sync fn crash and schema mutation (#3823)
* fix: TransformedTool sync fn crash, schema mutation, output_schema=False - Handle sync transform_fn in run() using is_coroutine_function check instead of unconditionally awaiting (fixes TypeError crash) - Deep copy parent property schemas to prevent mutation corruption - Accept output_schema=False via BeforeValidator (converts to None) - Remove inaccurate docstring claiming str/None shorthand for transform_args Fixes #3821 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add regression tests for sync transform_fn and schema mutation 🤖 Generated with Claude Code Co-authored-by: Jeremiah Lowin <jeremiah@lowin.dev> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <jeremiah@lowin.dev>
1 parent 790f0bc commit d0bcec9

2 files changed

Lines changed: 52 additions & 8 deletions

File tree

src/fastmcp/tools/tool_transform.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
from fastmcp.exceptions import FastMCPDeprecationWarning
2020
from fastmcp.tools.base import Tool, ToolResult, _convert_to_content
2121
from fastmcp.tools.function_parsing import ParsedFunction
22+
from fastmcp.utilities.async_utils import (
23+
call_sync_fn_in_threadpool,
24+
is_coroutine_function,
25+
)
2226
from fastmcp.utilities.components import _convert_set_default_none
2327
from fastmcp.utilities.json_schema import compress_schema
2428
from fastmcp.utilities.logging import get_logger
@@ -310,7 +314,12 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult:
310314

311315
token = _current_tool.set(self)
312316
try:
313-
result = await self.fn(**arguments)
317+
if is_coroutine_function(self.fn):
318+
result = await self.fn(**arguments)
319+
else:
320+
result = await call_sync_fn_in_threadpool(self.fn, **arguments)
321+
if inspect.isawaitable(result):
322+
result = await result
314323

315324
# If transform function returns ToolResult, respect our output_schema setting
316325
if isinstance(result, ToolResult):
@@ -385,17 +394,14 @@ def from_tool(
385394
version: New version for the tool. Defaults to parent tool's version.
386395
title: New title for the tool. Defaults to parent tool's title.
387396
transform_args: Optional transformations for parent tool arguments.
388-
Only specified arguments are transformed, others pass through unchanged:
389-
- Simple rename (str)
390-
- Complex transformation (rename/description/default/drop) (ArgTransform)
391-
- Drop the argument (None)
397+
Only specified arguments are transformed, others pass through unchanged.
398+
Use ArgTransform for rename, description, default, or hide operations.
392399
description: New description. Defaults to parent's description.
393400
tags: New tags. Defaults to parent's tags.
394401
annotations: New annotations. Defaults to parent's annotations.
395402
output_schema: Control output schema for structured outputs:
396403
- None (default): Inherit from transform_fn if available, then parent tool
397404
- dict: Use custom output schema
398-
- False: Disable output schema and structured outputs
399405
serializer: Deprecated. Return ToolResult from your tools for full control over serialization.
400406
meta: Control meta information:
401407
- NotSet (default): Inherit from parent tool
@@ -629,9 +635,9 @@ def _create_forwarding_transform(
629635
"""
630636

631637
# Build transformed schema and mapping
632-
# Deep copy to prevent compress_schema from mutating parent tool's $defs
638+
# Deep copy to prevent mutations from corrupting the parent tool's schema
633639
parent_defs = deepcopy(parent_tool.parameters.get("$defs", {}))
634-
parent_props = parent_tool.parameters.get("properties", {}).copy()
640+
parent_props = deepcopy(parent_tool.parameters.get("properties", {}))
635641
parent_required = set(parent_tool.parameters.get("required", []))
636642

637643
new_props = {}

tests/tools/tool_transform/test_tool_transform.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,3 +618,41 @@ async def test_transform_proxy(self, proxy_server: FastMCP):
618618
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
619619
assert isinstance(result.content[0], TextContent)
620620
assert result.content[0].text == "3"
621+
622+
623+
async def test_sync_transform_fn():
624+
"""Sync transform_fn should not crash when called (was unconditionally awaited)."""
625+
626+
@Tool.from_function
627+
def parent(x: int, y: int = 10) -> int:
628+
return x + y
629+
630+
def sync_transform(x: int, **kwargs) -> str:
631+
return f"transformed: {x}"
632+
633+
transformed = Tool.from_tool(parent, transform_fn=sync_transform)
634+
result = await transformed.run(arguments={"x": 7})
635+
assert isinstance(result.content[0], TextContent)
636+
assert result.content[0].text == "transformed: 7"
637+
638+
639+
async def test_transform_args_do_not_mutate_parent_schema():
640+
"""Mutating a transformed tool's schema must not corrupt the parent's schema."""
641+
642+
@Tool.from_function
643+
def parent(x: int, y: int = 10) -> int:
644+
return x + y
645+
646+
parent_props_before = {
647+
k: dict(v) for k, v in parent.parameters["properties"].items()
648+
}
649+
650+
transformed = Tool.from_tool(
651+
parent,
652+
transform_args={"x": ArgTransform(name="a")},
653+
)
654+
655+
transformed.parameters["properties"]["a"]["description"] = "INJECTED"
656+
657+
parent_props_after = parent.parameters["properties"]
658+
assert parent_props_after == parent_props_before

0 commit comments

Comments
 (0)