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>
This commit is contained in:
Bill Easton 2026-04-11 10:50:23 -05:00 committed by GitHub
commit d0bcec979c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 8 deletions

View file

@ -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 = {}

View file

@ -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