From d0bcec979c7353442b1b8d4d8488a03a1aaeeac0 Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Sat, 11 Apr 2026 10:50:23 -0500 Subject: [PATCH] fix: TransformedTool sync fn crash and schema mutation (#3823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * Add regression tests for sync transform_fn and schema mutation 🤖 Generated with Claude Code Co-authored-by: Jeremiah Lowin --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: Jeremiah Lowin --- src/fastmcp/tools/tool_transform.py | 22 +++++++---- .../tool_transform/test_tool_transform.py | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index a1ec42302..1dcc52662 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -19,6 +19,10 @@ import fastmcp from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.tools.base import Tool, ToolResult, _convert_to_content from fastmcp.tools.function_parsing import ParsedFunction +from fastmcp.utilities.async_utils import ( + call_sync_fn_in_threadpool, + is_coroutine_function, +) from fastmcp.utilities.components import _convert_set_default_none from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -310,7 +314,12 @@ class TransformedTool(Tool): token = _current_tool.set(self) try: - result = await self.fn(**arguments) + if is_coroutine_function(self.fn): + result = await self.fn(**arguments) + else: + result = await call_sync_fn_in_threadpool(self.fn, **arguments) + if inspect.isawaitable(result): + result = await result # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): @@ -385,17 +394,14 @@ class TransformedTool(Tool): version: New version for the tool. Defaults to parent tool's version. title: New title for the tool. Defaults to parent tool's title. transform_args: Optional transformations for parent tool arguments. - Only specified arguments are transformed, others pass through unchanged: - - Simple rename (str) - - Complex transformation (rename/description/default/drop) (ArgTransform) - - Drop the argument (None) + Only specified arguments are transformed, others pass through unchanged. + Use ArgTransform for rename, description, default, or hide operations. description: New description. Defaults to parent's description. tags: New tags. Defaults to parent's tags. annotations: New annotations. Defaults to parent's annotations. output_schema: Control output schema for structured outputs: - None (default): Inherit from transform_fn if available, then parent tool - dict: Use custom output schema - - False: Disable output schema and structured outputs serializer: Deprecated. Return ToolResult from your tools for full control over serialization. meta: Control meta information: - NotSet (default): Inherit from parent tool @@ -629,9 +635,9 @@ class TransformedTool(Tool): """ # Build transformed schema and mapping - # Deep copy to prevent compress_schema from mutating parent tool's $defs + # Deep copy to prevent mutations from corrupting the parent tool's schema parent_defs = deepcopy(parent_tool.parameters.get("$defs", {})) - parent_props = parent_tool.parameters.get("properties", {}).copy() + parent_props = deepcopy(parent_tool.parameters.get("properties", {})) parent_required = set(parent_tool.parameters.get("required", [])) new_props = {} diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index 810225121..ba5d46555 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -618,3 +618,41 @@ class TestProxy: result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "3" + + +async def test_sync_transform_fn(): + """Sync transform_fn should not crash when called (was unconditionally awaited).""" + + @Tool.from_function + def parent(x: int, y: int = 10) -> int: + return x + y + + def sync_transform(x: int, **kwargs) -> str: + return f"transformed: {x}" + + transformed = Tool.from_tool(parent, transform_fn=sync_transform) + result = await transformed.run(arguments={"x": 7}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "transformed: 7" + + +async def test_transform_args_do_not_mutate_parent_schema(): + """Mutating a transformed tool's schema must not corrupt the parent's schema.""" + + @Tool.from_function + def parent(x: int, y: int = 10) -> int: + return x + y + + parent_props_before = { + k: dict(v) for k, v in parent.parameters["properties"].items() + } + + transformed = Tool.from_tool( + parent, + transform_args={"x": ArgTransform(name="a")}, + ) + + transformed.parameters["properties"]["a"]["description"] = "INJECTED" + + parent_props_after = parent.parameters["properties"] + assert parent_props_after == parent_props_before