mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
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:
parent
790f0bcb47
commit
d0bcec979c
2 changed files with 52 additions and 8 deletions
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue